From 0670c3a639a43979e1992fb8b7a9d4ff00ac3ea3 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Mon, 5 Oct 2026 17:26:58 +0200 Subject: [PATCH 01/11] feat(agent): add addGateway() as the single gateway entry point (#1964) --- packages/_example/src/forest/agent.ts | 27 +- packages/agent/src/agent.ts | 108 ++++- packages/agent/src/embedded-bff.ts | 3 +- packages/agent/src/framework-mounter.ts | 2 +- packages/agent/src/gateway.ts | 102 +++++ packages/agent/src/index.ts | 8 +- packages/agent/src/types.ts | 16 + .../agent/test/agent-bff-missing-dep.test.ts | 14 + packages/agent/test/agent-gateway.test.ts | 425 ++++++++++++++++++ packages/agent/test/agent.test.ts | 6 +- packages/mcp-server/src/server.ts | 2 +- packages/mcp-server/test/server.test.ts | 13 + 12 files changed, 697 insertions(+), 29 deletions(-) create mode 100644 packages/agent/src/gateway.ts create mode 100644 packages/agent/test/agent-gateway.test.ts diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index 0282e67306..b98681c836 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -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, @@ -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(',') @@ -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) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index cd351aa568..a790b31ed8 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,9 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { GatewayServices } from './gateway'; import type { ForestAdminHttpDriverServices } from './services'; import type { AgentOptions, AgentOptionsWithDefaults, BffEmbedOptions, + GatewayOptions, + McpEmbedOptions, RootHandler, WorkflowExecutorEmbedOptions, } from './types'; @@ -31,6 +34,7 @@ import { BFF_PREFIX, collidesWithBff } from './bff-routes'; import EmbeddedBff from './embedded-bff'; import EmbeddedWorkflowExecutor from './embedded-workflow-executor'; import FrameworkMounter from './framework-mounter'; +import { assertNoGatewayOverlap, describeGatewayRoutes, resolveGatewayServices } from './gateway'; import makeRoutes from './routes'; import makeServices from './services'; import CustomizationService from './services/model-customizations/customization'; @@ -49,6 +53,10 @@ const bffMcpCollision = (mcpBasePath: string) => `server would claim ${BFF_PREFIX} paths the embedded BFF answers on (${BFF_PREFIX}/oauth, ` + `${BFF_PREFIX}/mcp). Mount the MCP server elsewhere.`; +const GATEWAY_MIXING = + 'addGateway() cannot be combined with mountAiMcpServer() or addBff(): configure every ' + + 'service through addGateway({ mcp, api }), or keep both aliases.'; + /** * Allow to create a new Forest Admin agent from scratch. * Builds the application by composing and configuring all the collection decorators. @@ -80,6 +88,8 @@ export default class Agent extends FrameworkMounter /** In-process BFF, created only when addBff() is called. */ private embeddedBff: EmbeddedBff | null = null; + private gateway: { basePath: string; services: GatewayServices } | null = null; + private isRestarting = false; /** @@ -128,14 +138,17 @@ export default class Agent extends FrameworkMounter // First, before anything is mounted or subscribed: everything it validates is what the caller // handed to addBff(), so a mistyped key must fail on that line rather than leave the host // serving /forest with a permanently bricked /bff. + await this.prepareGateway(); await this.embeddedBff?.prepare(); + this.warnIfMcpAliasUsed(); + const { router, mcp } = await this.buildRouterAndSendSchema(); await this.options.forestAdminClient.subscribeToServerEvents(); this.options.forestAdminClient.onRefreshCustomizations(this.restart.bind(this)); - this.setMcpCallback(mcp ?? null); + this.setMcpCallback(this.mcpRootHandler(mcp)); await this.mount(router); mounted = true; @@ -143,7 +156,9 @@ export default class Agent extends FrameworkMounter // standalone server's host/port (used to derive that URL) are only known once mounted. await this.embeddedExecutor?.start(this.standaloneServerHost, this.standaloneServerPort); // Same reason, without the socket: the dispatcher injects into the stack mount() just built. - await this.embeddedBff?.start(this.getInProcessDispatcher()); + if (!this.gateway) await this.embeddedBff?.start(this.getInProcessDispatcher()); + + this.logGatewayRoutes(); // Here rather than in initializeMcpServer(): that one reruns on every restart() and each run // would cost a SaaS round-trip for a warning already given. if (this.mcpEnabled) await this.warnIfMcpExemptFromIpWhitelist(); @@ -224,7 +239,7 @@ export default class Agent extends FrameworkMounter // We force sending schema when restarting const { router, mcp } = await this.buildRouterAndSendSchema(); - this.setMcpCallback(mcp ?? null); + this.setMcpCallback(this.mcpRootHandler(mcp)); await this.remount(router); // A restart means the customizations changed, so the schema the BFF read is stale. this.embeddedBff?.invalidate(); @@ -322,7 +337,37 @@ export default class Agent extends FrameworkMounter return this; } + addGateway(options: GatewayOptions): this { + if (this.gateway) throw new Error('addGateway can only be called once.'); + + if (this.startupBegun) { + throw new Error('addGateway must be called before start(): the agent is already starting.'); + } + + if (this.mcpEnabled || this.embeddedBff) throw new Error(GATEWAY_MIXING); + + const services = resolveGatewayServices(options); + this.gateway = { basePath: options.basePath ?? '', services }; + + if (services.mcp) { + this.mcpEnabled = true; + this.mcpEnabledTools = services.mcp.enabledTools; + this.mcpTokenTtl = services.mcp.tokenTtl; + this.mcpAllowedOAuthClients = options.allowedOAuthClients; + this.mcpFileUploads = services.mcp.fileUploads; + } + + if (services.api) { + this.embeddedBff = new EmbeddedBff(this.options, services.api, 'The Gateway API'); + this.getInProcessDispatcher(); + } + + return this; + } + /** + * @deprecated Use `addGateway({ mcp })`. Keeps its current paths and semantics. + * * Enable MCP (Model Context Protocol) server support. * This allows AI assistants to interact with your Forest Admin data. * @@ -352,13 +397,9 @@ export default class Agent extends FrameworkMounter * agent.mountAiMcpServer({ fileUploads: { storage } }); * agent.mountAiMcpServer({ fileUploads: false }); */ - mountAiMcpServer(options?: { - enabledTools?: ToolName[]; - basePath?: string; - tokenTtl?: TokenTtlOptions; - allowedOAuthClients?: string[]; - fileUploads?: false | FileUploadsOptions; - }): this { + mountAiMcpServer(options?: McpEmbedOptions): this { + if (this.gateway) throw new Error(GATEWAY_MIXING); + if (this.embeddedBff && collidesWithBff(options?.basePath)) { throw new Error(bffMcpCollision(options?.basePath as string)); } @@ -412,6 +453,8 @@ export default class Agent extends FrameworkMounter } /** + * @deprecated Use `addGateway({ api })`. Keeps serving on `/bff`. + * * Serve a BFF in-process, alongside the agent, at `/bff` — no second deployment, no second port. * The agent builds it on start(), stops it on stop(), and hands it a dispatcher that reaches its * own stack without a socket, so this works the same on every mount target. @@ -440,6 +483,8 @@ export default class Agent extends FrameworkMounter * .start(); */ addBff(options: BffEmbedOptions = {}): this { + if (this.gateway) throw new Error(GATEWAY_MIXING); + if (this.embeddedBff) { throw new Error('addBff can only be called once.'); } @@ -468,6 +513,49 @@ export default class Agent extends FrameworkMounter return this; } + private async prepareGateway(): Promise { + if (!this.gateway) return; + + const { services } = this.gateway; + + if (!services.mcp && !services.api) { + throw new Error( + 'addGateway() enables no service: pass mcp, api, or both, e.g. addGateway({ mcp: true }).', + ); + } + + const { normalizeMountPath } = await import('@forestadmin/mcp-server'); + const basePath = normalizeMountPath(this.gateway.basePath, 'basePath'); + + assertNoGatewayOverlap(basePath, this.completeMountPrefix, services); + this.gateway.basePath = basePath; + if (services.mcp) this.mcpBasePath = basePath || undefined; + } + + private warnIfMcpAliasUsed(): void { + if (!this.mcpEnabled || this.gateway) return; + + this.options.logger( + 'Warn', + '[MCP] mountAiMcpServer() is deprecated: use addGateway({ mcp }) instead. ' + + 'It keeps serving on its current paths.', + ); + } + + private mcpRootHandler(mcp?: RootHandler): RootHandler | null { + if (this.gateway) return null; + + return mcp ?? null; + } + + private logGatewayRoutes(): void { + if (!this.gateway) return; + + describeGatewayRoutes(this.gateway.basePath, this.gateway.services).forEach(line => + this.options.logger('Info', line), + ); + } + protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) { return makeRoutes(dataSource, this.options, services); } diff --git a/packages/agent/src/embedded-bff.ts b/packages/agent/src/embedded-bff.ts index b64ed66a5c..98def5413d 100644 --- a/packages/agent/src/embedded-bff.ts +++ b/packages/agent/src/embedded-bff.ts @@ -41,6 +41,7 @@ export default class EmbeddedBff { constructor( private readonly options: AgentOptionsWithDefaults, private readonly embedOptions: BffEmbedOptions, + private readonly name = 'The embedded BFF', ) {} /** @@ -232,7 +233,7 @@ export default class EmbeddedBff { // throw during the package's own evaluation — none of which "install it" would fix. const { message } = error as Error; const wrapped = new Error( - `The embedded BFF requires the \`@forestadmin/agent-bff\` package, which failed to ` + + `${this.name} requires the \`@forestadmin/agent-bff\` package, which failed to ` + `load: ${message}. Install it with \`npm install @forestadmin/agent-bff\`.`, ); // Assigned rather than passed to the constructor: the repo targets ES2020, whose lib types no diff --git a/packages/agent/src/framework-mounter.ts b/packages/agent/src/framework-mounter.ts index 4285877f3f..6ec461fe5f 100644 --- a/packages/agent/src/framework-mounter.ts +++ b/packages/agent/src/framework-mounter.ts @@ -30,7 +30,7 @@ export default class FrameworkMounter { private inProcessHookRegistered = false; /** Compute the prefix that the main router should be mounted at in the client's application */ - private get completeMountPrefix(): string { + protected get completeMountPrefix(): string { return path.posix.join('/', this.prefix, 'forest'); } diff --git a/packages/agent/src/gateway.ts b/packages/agent/src/gateway.ts new file mode 100644 index 0000000000..6da358f807 --- /dev/null +++ b/packages/agent/src/gateway.ts @@ -0,0 +1,102 @@ +import type { BffEmbedOptions, GatewayOptions, McpEmbedOptions } from './types'; + +export type GatewayMcpOptions = Omit; + +export type GatewayServices = { mcp: GatewayMcpOptions | null; api: BffEmbedOptions | null }; + +const TOP_LEVEL_OPTIONS = ['basePath', 'allowedOAuthClients'] as const; + +function resolveService(service: T | boolean | undefined): T | null { + if (service === true) return {} as T; + + return service || null; +} + +export function resolveGatewayServices({ mcp, api }: GatewayOptions): GatewayServices { + const mcpOptions = resolveService(mcp); + const nested = TOP_LEVEL_OPTIONS.find(option => mcpOptions && option in mcpOptions); + + if (nested) { + throw new Error( + `addGateway({ mcp: { ${nested} } }) is not supported: ${nested} covers every Gateway ` + + `service, set it as addGateway({ ${nested} }).`, + ); + } + + return { mcp: mcpOptions, api: resolveService(api) }; +} + +const isWithin = (path: string, root: string) => path === root || path.startsWith(`${root}/`); + +export function assertNoGatewayOverlap( + basePath: string, + forestPath: string, + { mcp, api }: GatewayServices, +): void { + if (basePath && isWithin(basePath, forestPath)) { + throw new Error( + `basePath "${basePath}" overlaps the agent routes on ${forestPath}: ` + + 'change the Gateway basePath or the agent prefix.', + ); + } + + const claimed = [ + ...(mcp + ? [ + { path: `${basePath}/mcp`, service: 'MCP' }, + { path: `${basePath}/oauth`, service: 'MCP' }, + ] + : []), + ...(api ? [{ path: `${basePath}/api/agent`, service: 'API' }] : []), + ]; + const overlap = claimed.find(({ path }) => isWithin(forestPath, path)); + + if (overlap) { + throw new Error( + `The agent routes on ${forestPath} overlap ${overlap.path}, claimed by the Gateway ` + + `${overlap.service}: change the agent prefix or the Gateway basePath.`, + ); + } +} + +function servesInMemoryUploads({ fileUploads, enabledTools }: GatewayMcpOptions): boolean { + if (fileUploads === false || fileUploads?.storage) return false; + + return !enabledTools || enabledTools.includes('requestActionFileUpload'); +} + +export function describeGatewayRoutes(basePath: string, { mcp, api }: GatewayServices): string[] { + const lines: string[] = []; + + if (mcp) { + const discovery = basePath + ? [ + `/.well-known/oauth-authorization-server${basePath}`, + `/.well-known/oauth-protected-resource${basePath}/mcp`, + ] + : ['/.well-known/*']; + const routes = [ + `${basePath}/mcp`, + ...(servesInMemoryUploads(mcp) ? [`${basePath}/mcp/uploads`] : []), + `${basePath}/oauth/*`, + ...discovery, + ]; + + lines.push(`[Gateway] MCP: ${routes.join(', ')}`); + } + + if (api) { + const routes = [ + `${basePath}/api/agent/*`, + `${basePath}/api/health`, + ...(api.openapiEnabled ? [`${basePath}/api/docs`] : []), + ]; + const oauth = api.tokenEncryptionKey + ? `, ${basePath}/oauth/*?service=api` + : ' (API OAuth off: no tokenEncryptionKey)'; + + lines.push(`[Gateway] API: ${routes.join(', ')}${oauth}`); + } + + return lines; +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 5fdbb9d197..51fb304b2c 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -8,7 +8,13 @@ export function createAgent(options: AgentOptions): } export { Agent }; -export { AgentOptions, BffEmbedOptions, WorkflowExecutorEmbedOptions } from './types'; +export { + AgentOptions, + BffEmbedOptions, + GatewayOptions, + McpEmbedOptions, + WorkflowExecutorEmbedOptions, +} from './types'; export * from '@forestadmin/datasource-customizer'; // export is necessary for the agent-generator package diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 8cb9746c36..ec6c5639d3 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,6 +1,7 @@ import type { AuditStore } from './audit-trail/types'; import type { CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit'; import type { ForestAdminClient } from '@forestadmin/forestadmin-client'; +import type { FileUploadsOptions, TokenTtlOptions, ToolName } from '@forestadmin/mcp-server'; import type { WorkflowExecutorTuningOptions } from '@forestadmin/workflow-executor'; import type { IncomingMessage, ServerResponse } from 'http'; @@ -208,6 +209,21 @@ export type BffEmbedOptions = { shutdownTimeoutMs?: number; }; +export type McpEmbedOptions = { + enabledTools?: ToolName[]; + basePath?: string; + tokenTtl?: TokenTtlOptions; + allowedOAuthClients?: string[]; + fileUploads?: false | FileUploadsOptions; +}; + +export type GatewayOptions = { + basePath?: string; + allowedOAuthClients?: string[]; + mcp?: Omit | boolean; + api?: BffEmbedOptions | boolean; +}; + // Runtime view of `auditTrail`: the validator has built the SQL store from the connection string. export type AuditTrailRuntime = AuditTrailConfig & { store: AuditStore; diff --git a/packages/agent/test/agent-bff-missing-dep.test.ts b/packages/agent/test/agent-bff-missing-dep.test.ts index 4f873814a4..1b36601f71 100644 --- a/packages/agent/test/agent-bff-missing-dep.test.ts +++ b/packages/agent/test/agent-bff-missing-dep.test.ts @@ -56,3 +56,17 @@ describe('Agent.addBff (optional dependency missing)', () => { expect(error.cause?.message).toBe("Cannot find module '@forestadmin/agent-bff'"); }); }); + +describe('Agent.addGateway({ api }) (optional dependency missing)', () => { + it('should name the Gateway API and give the install command', async () => { + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ prefix: '', skipSchemaUpdate: true }), + ).addGateway({ api: true }); + + await expect(agent.start()).rejects.toThrow( + 'The Gateway API requires the `@forestadmin/agent-bff` package, which failed to load: ' + + "Cannot find module '@forestadmin/agent-bff'. " + + 'Install it with `npm install @forestadmin/agent-bff`.', + ); + }); +}); diff --git a/packages/agent/test/agent-gateway.test.ts b/packages/agent/test/agent-gateway.test.ts new file mode 100644 index 0000000000..db6462f67c --- /dev/null +++ b/packages/agent/test/agent-gateway.test.ts @@ -0,0 +1,425 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { GatewayOptions } from '../src/types'; +import type { UploadStorage } from '@forestadmin/mcp-server'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; +import * as McpServer from '@forestadmin/mcp-server'; + +import * as factories from './__factories__'; +import Agent from '../src/agent'; + +const mockMakeRoutes = jest.fn(); + +jest.mock('../src/routes', () => ({ + __esModule: true, + default: (...args) => mockMakeRoutes(...args), +})); + +jest.mock('@forestadmin/datasource-customizer'); + +const mockParseConfig = jest.fn(); +const mockBuildBff = jest.fn(); + +jest.mock('@forestadmin/agent-bff', () => ({ + __esModule: true, + IN_PROCESS_AGENT_URL: 'http://in-process.agent', + parseConfig: (env: unknown) => mockParseConfig(env), + buildBff: (options: unknown) => mockBuildBff(options), +})); + +let mcpServerSpy: jest.SpyInstance; +let mockGetHttpCallback: jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + + mockMakeRoutes.mockReturnValue([{ setupRoutes: jest.fn(), bootstrap: jest.fn() }]); + mockParseConfig.mockReturnValue({}); + mockBuildBff.mockResolvedValue({ callback: jest.fn(), invalidate: jest.fn() }); + jest + .mocked(DataSourceCustomizer.prototype.getDataSource) + .mockResolvedValue(factories.dataSource.build()); + + mockGetHttpCallback = jest.fn().mockResolvedValue(jest.fn()); + mcpServerSpy = jest + .spyOn(McpServer, 'ForestMCPServer') + .mockImplementation(() => ({ getHttpCallback: mockGetHttpCallback } as any)); +}); + +const runningAgents: Agent[] = []; + +afterEach(async () => { + mcpServerSpy.mockRestore(); + await Promise.all(runningAgents.splice(0).map(agent => agent.stop())); +}); + +function buildAgent(prefix = '') { + const logger = jest.fn(); + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ prefix, logger, skipSchemaUpdate: true }), + ); + + return { agent, logger }; +} + +function answeringWith(body: string) { + return (_req: IncomingMessage, res: ServerResponse) => res.end(body); +} + +async function startOnPort(agent: Agent) { + runningAgents.push(agent); + agent.mountOnStandaloneServer(0, '127.0.0.1'); + await agent.start(); + + return async (path: string) => { + const response = await fetch(`http://127.0.0.1:${agent.standaloneServerPort}${path}`); + + return { status: response.status, body: await response.text() }; + }; +} + +function gatewayLog(logger: jest.Mock, service: 'MCP' | 'API'): string { + const line = logger.mock.calls + .map(([, message]) => message as string) + .find(message => message.startsWith(`[Gateway] ${service}:`)); + + if (!line) throw new Error(`No [Gateway] ${service} boot log line`); + + return line; +} + +describe('Agent.addGateway', () => { + it('should return the agent so it can be chained', () => { + const { agent } = buildAgent(); + + expect(agent.addGateway({ mcp: true })).toBe(agent); + }); + + describe('when neither addGateway() nor an alias is called', () => { + it('should start with no error, no Gateway log and no Gateway route', async () => { + const { agent, logger } = buildAgent(); + + const request = await startOnPort(agent); + + expect(mcpServerSpy).not.toHaveBeenCalled(); + expect(mockParseConfig).not.toHaveBeenCalled(); + expect(logger).not.toHaveBeenCalledWith('Info', expect.stringContaining('[Gateway]')); + expect((await request('/mcp')).status).toBe(404); + expect((await request('/api/health')).status).toBe(404); + }); + }); + + describe('boot log', () => { + it('should list the MCP and API routes at the root with an empty basePath', async () => { + const { agent, logger } = buildAgent(); + agent.addGateway({ mcp: true, api: { tokenEncryptionKey: 'key' } }); + + await agent.start(); + + expect(gatewayLog(logger, 'MCP')).toBe( + '[Gateway] MCP: /mcp, /mcp/uploads, /oauth/*, /.well-known/*', + ); + expect(gatewayLog(logger, 'API')).toBe( + '[Gateway] API: /api/agent/*, /api/health, /oauth/*?service=api', + ); + }); + + it('should list /api/docs only when openapiEnabled is set', async () => { + const { agent, logger } = buildAgent(); + agent.addGateway({ api: { tokenEncryptionKey: 'key', openapiEnabled: true } }); + + await agent.start(); + + expect(gatewayLog(logger, 'API')).toBe( + '[Gateway] API: /api/agent/*, /api/health, /api/docs, /oauth/*?service=api', + ); + }); + + it('should say "API OAuth off" and list no ?service=api route without tokenEncryptionKey', async () => { + const { agent, logger } = buildAgent(); + agent.addGateway({ api: {} }); + + await agent.start(); + + expect(gatewayLog(logger, 'API')).toBe( + '[Gateway] API: /api/agent/*, /api/health (API OAuth off: no tokenEncryptionKey)', + ); + }); + + it('should prefix every route with the basePath and claim only the suffixed .well-known subtrees', async () => { + const { agent, logger } = buildAgent(); + agent.addGateway({ basePath: '/ai', mcp: true, api: { tokenEncryptionKey: 'key' } }); + + await agent.start(); + + expect(gatewayLog(logger, 'MCP')).toBe( + '[Gateway] MCP: /ai/mcp, /ai/mcp/uploads, /ai/oauth/*, ' + + '/.well-known/oauth-authorization-server/ai, /.well-known/oauth-protected-resource/ai/mcp', + ); + expect(gatewayLog(logger, 'API')).toBe( + '[Gateway] API: /ai/api/agent/*, /ai/api/health, /ai/oauth/*?service=api', + ); + }); + + it.each<[string, Exclude]>([ + ['are off', { fileUploads: false }], + ['go to an external storage', { fileUploads: { storage: {} as UploadStorage } }], + ['are left out of enabledTools', { enabledTools: ['list'] }], + ])('should not list the uploads route when file uploads %s', async (_case, mcp) => { + const { agent, logger } = buildAgent(); + agent.addGateway({ mcp }); + + await agent.start(); + + expect(gatewayLog(logger, 'MCP')).toBe('[Gateway] MCP: /mcp, /oauth/*, /.well-known/*'); + }); + + it('should log only the enabled service', async () => { + const { agent, logger } = buildAgent(); + agent.addGateway({ mcp: true, api: false }); + + await agent.start(); + + expect(gatewayLog(logger, 'MCP')).toBe( + '[Gateway] MCP: /mcp, /mcp/uploads, /oauth/*, /.well-known/*', + ); + expect(() => gatewayLog(logger, 'API')).toThrow(); + }); + }); + + describe('services', () => { + it('should start the API with its defaults when api is true', async () => { + const { agent } = buildAgent(); + agent.addGateway({ api: true }); + + await agent.start(); + + expect(mockParseConfig).toHaveBeenCalledWith( + expect.objectContaining({ + BFF_TOKEN_ENCRYPTION_KEY: undefined, + BFF_ALLOWED_ORIGINS: undefined, + BFF_OPENAPI_ENABLED: 'false', + }), + ); + }); + + it('should hand the MCP its options, the normalized basePath and the Gateway allowlist', async () => { + const { agent } = buildAgent(); + agent.addGateway({ + basePath: 'ai/', + allowedOAuthClients: ['dust.tt'], + mcp: { enabledTools: ['list'], tokenTtl: { accessTokenSeconds: 900 } }, + }); + + await agent.start(); + + expect(mcpServerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + basePath: '/ai', + allowedOAuthClients: ['dust.tt'], + enabledTools: ['list'], + tokenTtl: { accessTokenSeconds: 900 }, + }), + ); + }); + + it('should not load the MCP server when only the API is on', async () => { + const { agent } = buildAgent(); + agent.addGateway({ api: {} }); + + await agent.start(); + + expect(mcpServerSpy).not.toHaveBeenCalled(); + }); + + it('should leave every Gateway path to the host: the Gateway routing ships separately', async () => { + const { agent } = buildAgent(); + mockGetHttpCallback.mockResolvedValue(answeringWith('mcp')); + agent.addGateway({ mcp: true, api: {} }); + + const request = await startOnPort(agent); + + expect((await request('/mcp')).status).toBe(404); + expect((await request('/oauth/token')).status).toBe(404); + expect((await request('/api/health')).status).toBe(404); + }); + + it('should surface the MCP domain-root rejection from start()', async () => { + const { agent } = buildAgent(); + const rejection = new Error( + 'basePath "/ai" requires the agent to be served at the domain root, but its base URL ' + + 'has a path ("/app"). Remove the basePath or mount the agent at the root.', + ); + mockGetHttpCallback.mockRejectedValue(rejection); + agent.addGateway({ basePath: '/ai', mcp: true }); + + await expect(agent.start()).rejects.toThrow(rejection.message); + expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/ai' })); + }); + }); + + describe('option checks', () => { + it.each(['basePath', 'allowedOAuthClients'])( + 'should refuse a nested mcp.%s, naming the top-level option', + option => { + const { agent } = buildAgent(); + const options = { mcp: { [option]: option === 'basePath' ? '/x' : ['dust.tt'] } }; + + expect(() => agent.addGateway(options as unknown as GatewayOptions)).toThrow( + `addGateway({ ${option} })`, + ); + }, + ); + + it('should refuse an invalid basePath at start(), naming basePath and the format, before mounting', async () => { + const { agent } = buildAgent(); + agent.addGateway({ basePath: 'a b', mcp: true }).mountOnStandaloneServer(0, '127.0.0.1'); + + await expect(agent.start()).rejects.toThrow( + 'Invalid basePath "a b": use a plain path prefix like "/mcp" ' + + '(letters, digits, "-" and "_" only).', + ); + expect(agent.standaloneServerPort).toBeUndefined(); + expect(mcpServerSpy).not.toHaveBeenCalled(); + }); + + it.each([{}, { mcp: false, api: false }])( + 'should fail start() with a clear error when no service is on (%j)', + async options => { + const { agent } = buildAgent(); + agent.addGateway(options); + + await expect(agent.start()).rejects.toThrow( + 'addGateway() enables no service: pass mcp, api, or both', + ); + }, + ); + }); + + describe('call guards', () => { + it('should refuse a second call', () => { + const { agent } = buildAgent(); + agent.addGateway({ mcp: true }); + + expect(() => agent.addGateway({ api: {} })).toThrow('addGateway can only be called once.'); + }); + + it('should refuse a call once start() has begun', async () => { + const { agent } = buildAgent(); + await agent.start(); + + expect(() => agent.addGateway({ mcp: true })).toThrow( + 'addGateway must be called before start(): the agent is already starting.', + ); + }); + + const mixing = 'cannot be combined with'; + + it('should refuse addGateway() after mountAiMcpServer()', () => { + const { agent } = buildAgent(); + agent.mountAiMcpServer(); + + expect(() => agent.addGateway({ api: {} })).toThrow(mixing); + }); + + it('should refuse mountAiMcpServer() after addGateway()', () => { + const { agent } = buildAgent(); + agent.addGateway({ api: {} }); + + expect(() => agent.mountAiMcpServer()).toThrow(mixing); + }); + + it('should refuse addGateway() after addBff()', () => { + const { agent } = buildAgent(); + agent.addBff(); + + expect(() => agent.addGateway({ mcp: true })).toThrow(mixing); + }); + + it('should refuse addBff() after addGateway()', () => { + const { agent } = buildAgent(); + agent.addGateway({ mcp: true }); + + expect(() => agent.addBff()).toThrow(mixing); + }); + }); + + describe('route overlap with the agent', () => { + it.each` + prefix | options | message + ${''} | ${{ basePath: '/forest', mcp: true }} | ${'basePath "/forest" overlaps the agent routes on /forest'} + ${'api'} | ${{ basePath: '/api/forest/x', api: {} }} | ${'basePath "/api/forest/x" overlaps the agent routes on /api/forest'} + ${'mcp'} | ${{ mcp: true }} | ${'The agent routes on /mcp/forest overlap /mcp, claimed by the Gateway MCP'} + ${'oauth'} | ${{ mcp: true }} | ${'The agent routes on /oauth/forest overlap /oauth, claimed by the Gateway MCP'} + ${'ai/mcp'} | ${{ basePath: '/ai', mcp: true }} | ${'The agent routes on /ai/mcp/forest overlap /ai/mcp, claimed by the Gateway MCP'} + ${'api/agent'} | ${{ api: {} }} | ${'The agent routes on /api/agent/forest overlap /api/agent, claimed by the Gateway API'} + `('should refuse prefix "$prefix" with $options', async ({ prefix, options, message }) => { + const { agent } = buildAgent(prefix); + agent.addGateway(options); + + await expect(agent.start()).rejects.toThrow(message); + }); + + it.each` + prefix | options + ${'api'} | ${{ mcp: true }} + ${'api'} | ${{ api: {} }} + ${'api/docs'} | ${{ api: { openapiEnabled: true } }} + ${'api/health'} | ${{ api: {} }} + ${'mcp'} | ${{ api: {} }} + ${'api/agent'} | ${{ mcp: true }} + ${'mcp'} | ${{ basePath: '/ai', mcp: true }} + ${''} | ${{ basePath: '/forestx', mcp: true }} + ${'mcpx'} | ${{ mcp: true }} + `('should accept prefix "$prefix" with $options', async ({ prefix, options }) => { + const { agent } = buildAgent(prefix); + agent.addGateway(options); + + await expect(agent.start()).resolves.toBeUndefined(); + }); + }); +}); + +describe('Agent.mountAiMcpServer (deprecated alias)', () => { + it('should keep the last configuration when called twice, at the root', async () => { + const { agent } = buildAgent(); + mockGetHttpCallback.mockResolvedValue(answeringWith('mcp')); + agent.mountAiMcpServer({ enabledTools: ['list'] }); + agent.mountAiMcpServer({ enabledTools: ['describeCollection'] }); + + const request = await startOnPort(agent); + + expect(mcpServerSpy).toHaveBeenCalledTimes(1); + expect(mcpServerSpy).toHaveBeenCalledWith( + expect.objectContaining({ enabledTools: ['describeCollection'], basePath: undefined }), + ); + expect(await request('/mcp')).toEqual({ status: 200, body: 'mcp' }); + }); + + it('should log a deprecation warning naming addGateway() on start', async () => { + const { agent, logger } = buildAgent(); + agent.mountAiMcpServer(); + + await agent.start(); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringMatching(/mountAiMcpServer\(\) is deprecated.*addGateway\(\{ mcp \}\)/), + ); + }); +}); + +describe('Agent.addBff (deprecated alias)', () => { + it('should keep serving on /bff and claim nothing on /api', async () => { + const { agent } = buildAgent(); + mockBuildBff.mockResolvedValue({ callback: answeringWith('bff'), invalidate: jest.fn() }); + agent.addBff(); + + const request = await startOnPort(agent); + + expect(await request('/bff/agent/v1/books/list')).toEqual({ status: 200, body: 'bff' }); + expect((await request('/api/agent/v1/books/list')).status).toBe(404); + expect((await request('/api/health')).status).toBe(404); + }); +}); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index c751174fd4..3c28aff2f8 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -814,9 +814,9 @@ describe('Agent', () => { agent.mountAiMcpServer(); await agent.start(); - expect(logger.mock.calls.filter(([level]) => level === 'Warn')).toEqual([ - ['Warn', warning], - ]); + expect(logger.mock.calls.filter(([, message]) => message.includes('IP whitelist'))).toEqual( + [['Warn', warning]], + ); }); test('prefixes the warned routes with the MCP basePath', async () => { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index e52040072f..dd9376315b 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -575,7 +575,7 @@ export default class ForestMCPServer { // agent itself is at the domain root. Fail loudly rather than advertise URLs we can't serve. if (prefix && effectiveBaseUrl.pathname !== '/') { throw new Error( - `MCP basePath "${prefix}" requires the agent to be served at the domain root, but its ` + + `basePath "${prefix}" requires the agent to be served at the domain root, but its ` + `base URL has a path ("${effectiveBaseUrl.pathname}"). Remove the basePath or mount ` + `the agent at the root.`, ); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 31b835d79e..6563694c4f 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2905,6 +2905,19 @@ describe('basePath prefix', () => { /requires the agent to be served at the domain root/, ); }); + + it('names the basePath and the agent path in the rejection, not an MCP-only option', async () => { + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + basePath: '/ai', + }); + + await expect(server.buildExpressApp(new URL('https://host/app'))).rejects.toThrow( + /^basePath "\/ai" requires the agent to be served at the domain root, .*\("\/app"\)/, + ); + }); }); describe('getHttpCallback route filtering under /mcp', () => { From 5ba9cfef7ca51ffdf9b7d241513882cd85f9784f Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 6 Oct 2026 14:16:01 +0200 Subject: [PATCH 02/11] feat(agent): serve addGateway() through the gateway switch (#1967) --- packages/agent/src/agent.ts | 91 ++- packages/agent/src/bff-routes.ts | 70 ++ packages/agent/src/embedded-bff.ts | 64 +- packages/agent/src/framework-mounter.ts | 4 + packages/agent/src/gateway.ts | 52 +- packages/agent/src/utils/express-to-koa.ts | 2 + .../agent/test/agent-gateway-wiring.test.ts | 628 ++++++++++++++++++ packages/agent/test/agent-gateway.test.ts | 22 +- packages/agent/test/bff-routes.test.ts | 7 + .../test/gateway/embedded-gateway.e2e.test.ts | 451 +++++++++++++ .../agent/test/utils/express-to-koa.test.ts | 54 ++ 11 files changed, 1386 insertions(+), 59 deletions(-) create mode 100644 packages/agent/test/agent-gateway-wiring.test.ts create mode 100644 packages/agent/test/gateway/embedded-gateway.e2e.test.ts create mode 100644 packages/agent/test/utils/express-to-koa.test.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index a790b31ed8..81faa0f715 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -30,11 +30,17 @@ import { readFile, writeFile } from 'fs/promises'; import stringify from 'json-stringify-pretty-compact'; import { installAuditTrailHooks } from './audit-trail'; -import { BFF_PREFIX, collidesWithBff } from './bff-routes'; +import { BFF_PREFIX, collidesWithBff, createBffAliasCallback } from './bff-routes'; import EmbeddedBff from './embedded-bff'; import EmbeddedWorkflowExecutor from './embedded-workflow-executor'; import FrameworkMounter from './framework-mounter'; -import { assertNoGatewayOverlap, describeGatewayRoutes, resolveGatewayServices } from './gateway'; +import { + AGENT_BFF_PEER_VERSION, + assertNoGatewayOverlap, + describeGatewayRoutes, + mcpUnavailable, + resolveGatewayServices, +} from './gateway'; import makeRoutes from './routes'; import makeServices from './services'; import CustomizationService from './services/model-customizations/customization'; @@ -88,9 +94,14 @@ export default class Agent extends FrameworkMounter /** In-process BFF, created only when addBff() is called. */ private embeddedBff: EmbeddedBff | null = null; - private gateway: { basePath: string; services: GatewayServices } | null = null; + private gateway: { + basePath: string; + services: GatewayServices; + build?: (mcp?: RootHandler) => RootHandler; + } | null = null; private isRestarting = false; + private isStopped = false; /** * Set as soon as start() begins, not once it finishes: mount() drains the `onFirstStart` hooks @@ -133,6 +144,7 @@ export default class Agent extends FrameworkMounter async start(): Promise { let mounted = false; this.startupBegun = true; + this.isStopped = false; try { // First, before anything is mounted or subscribed: everything it validates is what the caller @@ -140,23 +152,29 @@ export default class Agent extends FrameworkMounter // serving /forest with a permanently bricked /bff. await this.prepareGateway(); await this.embeddedBff?.prepare(); + if (this.isStopped) await this.embeddedBff?.stop(); + if (this.gateway) this.setRootHandlers(); + if (this.isStopped) return; this.warnIfMcpAliasUsed(); const { router, mcp } = await this.buildRouterAndSendSchema(); + if (this.isStopped) return; + await this.options.forestAdminClient.subscribeToServerEvents(); this.options.forestAdminClient.onRefreshCustomizations(this.restart.bind(this)); - this.setMcpCallback(this.mcpRootHandler(mcp)); + this.setRootHandlers(mcp); await this.mount(router); mounted = true; // Boot after mount(): the embedded executor reaches the agent over HTTP, and the // standalone server's host/port (used to derive that URL) are only known once mounted. await this.embeddedExecutor?.start(this.standaloneServerHost, this.standaloneServerPort); + if (this.isStopped) return; // Same reason, without the socket: the dispatcher injects into the stack mount() just built. - if (!this.gateway) await this.embeddedBff?.start(this.getInProcessDispatcher()); + await this.embeddedBff?.start(this.getInProcessDispatcher()); this.logGatewayRoutes(); // Here rather than in initializeMcpServer(): that one reruns on every restart() and each run @@ -196,6 +214,10 @@ export default class Agent extends FrameworkMounter * Stop the agent. */ override async stop(): Promise { + this.isStopped = true; + + if (this.gateway?.build) this.setRootHandlers(); + // Stop answering before the stack it dispatches into goes away: the host application keeps // whatever middleware it registered, so a stopped agent would otherwise still serve BFF data. await this.embeddedBff?.stop(); @@ -239,7 +261,9 @@ export default class Agent extends FrameworkMounter // We force sending schema when restarting const { router, mcp } = await this.buildRouterAndSendSchema(); - this.setMcpCallback(this.mcpRootHandler(mcp)); + if (this.isStopped) return; + + this.setRootHandlers(mcp); await this.remount(router); // A restart means the customizations changed, so the schema the BFF read is stale. this.embeddedBff?.invalidate(); @@ -357,10 +381,7 @@ export default class Agent extends FrameworkMounter this.mcpFileUploads = services.mcp.fileUploads; } - if (services.api) { - this.embeddedBff = new EmbeddedBff(this.options, services.api, 'The Gateway API'); - this.getInProcessDispatcher(); - } + if (services.api) this.getInProcessDispatcher(); return this; } @@ -502,13 +523,13 @@ export default class Agent extends FrameworkMounter throw new Error(bffMcpCollision(this.mcpBasePath as string)); } - const bff = new EmbeddedBff(this.options, options); + const bff = new EmbeddedBff(this.options, options, { prefix: BFF_PREFIX }); this.embeddedBff = bff; // Registered now rather than at start(): getInProcessDispatcher() pushes its hook the first // time it is called, and mount() only runs the hooks registered before it — asked for later, // the dispatcher would have no handler until the first restart. this.getInProcessDispatcher(); - this.setBffCallback(bff.handle); + this.setBffCallback(createBffAliasCallback(bff.handle, this.options.logger)); return this; } @@ -524,12 +545,34 @@ export default class Agent extends FrameworkMounter ); } - const { normalizeMountPath } = await import('@forestadmin/mcp-server'); + const { createGatewaySwitch, normalizeMountPath } = await import('@forestadmin/mcp-server'); const basePath = normalizeMountPath(this.gateway.basePath, 'basePath'); assertNoGatewayOverlap(basePath, this.completeMountPrefix, services); this.gateway.basePath = basePath; if (services.mcp) this.mcpBasePath = basePath || undefined; + + const bff = services.api ? this.gatewayApi(basePath, services.api) : null; + const api = bff ? { matches: (path: string) => bff.claims(path), callback: bff.handle } : null; + const mcpStarting = services.mcp ? mcpUnavailable(basePath, 'starting') : null; + + this.gateway.build = mcp => + createGatewaySwitch({ + basePath, + mcp: mcp ?? mcpStarting ?? undefined, + api: api ?? undefined, + }); + } + + private gatewayApi(basePath: string, options: BffEmbedOptions): EmbeddedBff { + this.embeddedBff ??= new EmbeddedBff(this.options, options, { + prefix: `${basePath}/api`, + name: 'The Gateway API', + requiredVersion: AGENT_BFF_PEER_VERSION, + skipIpWhitelistWarning: Boolean(this.gateway?.services.mcp), + }); + + return this.embeddedBff; } private warnIfMcpAliasUsed(): void { @@ -542,10 +585,19 @@ export default class Agent extends FrameworkMounter ); } - private mcpRootHandler(mcp?: RootHandler): RootHandler | null { - if (this.gateway) return null; + private setRootHandlers(mcp?: RootHandler): void { + if (!this.gateway?.build) { + this.setMcpCallback(mcp ?? null); + + return; + } + + const stopped = + this.isStopped && this.gateway.services.mcp + ? mcpUnavailable(this.gateway.basePath, 'stopped') + : undefined; - return mcp ?? null; + this.setGatewayCallback(this.gateway.build(stopped ?? mcp)); } private logGatewayRoutes(): void { @@ -614,6 +666,11 @@ export default class Agent extends FrameworkMounter if (!isFeatureEnabled) return; const base = normalizeMcpBasePath(this.mcpBasePath); + const apiNote = this.gateway?.services.api + ? ` The Gateway API on ${base}/api/* escapes it too: it reaches the agent in-process, ` + + 'which the whitelist exempts as a trusted loopback caller. API calls still require an ' + + 'API key or an OAuth session.' + : ''; this.options.logger( 'Warn', @@ -622,7 +679,7 @@ export default class Agent extends FrameworkMounter `/.well-known/oauth-authorization-server${base}, ` + `/.well-known/oauth-protected-resource${base}/mcp): its middleware is mounted on the ` + `/forest router only, so these routes escape it for any caller. Tool calls on ${base}/mcp ` + - 'still require a valid MCP OAuth token.', + `still require a valid MCP OAuth token.${apiNote}`, ); } catch (error) { this.options.logger( diff --git a/packages/agent/src/bff-routes.ts b/packages/agent/src/bff-routes.ts index 383816ebca..d8d8e6ce06 100644 --- a/packages/agent/src/bff-routes.ts +++ b/packages/agent/src/bff-routes.ts @@ -1,3 +1,7 @@ +import type { HttpCallback } from './types'; +import type { Logger } from '@forestadmin/datasource-toolkit'; +import type { ServerResponse } from 'http'; + /** * Where an embedded BFF answers, at the root of the host application. Fixed: the BFF serves its own * `/oauth/*` and `/docs`, which would otherwise collide with the MCP server's root paths and with @@ -20,6 +24,8 @@ export function isBffRoute(url: string): boolean { * as a malformed request rather than as the root. */ export function stripBffPrefix(url: string): string { + if (!isBffRoute(url)) return url; + const remainder = url.slice(BFF_PREFIX.length); if (remainder === '') return '/'; @@ -27,6 +33,70 @@ export function stripBffPrefix(url: string): string { return remainder.startsWith('/') ? remainder : `/${remainder}`; } +export const BFF_DEPRECATION_TIMESTAMP = Date.UTC(2026, 9, 5) / 1000; +export const BFF_DEPRECATION_LINK = 'https://docs.forestadmin.com/product/embed/bff'; +export const BFF_DEPRECATION_LOG_INTERVAL_MS = 60 * 60 * 1000; + +const EXPOSE_HEADERS = 'Access-Control-Expose-Headers'; +const DEPRECATION_HEADERS = ['Deprecation', 'Link']; +const DEPRECATION_LINK = `<${BFF_DEPRECATION_LINK}>; rel="deprecation"`; + +function headerValues(res: ServerResponse, name: string): string[] { + const value = res.getHeader(name); + + return value === undefined ? [] : [value].flat().map(String); +} + +function markDeprecated(res: ServerResponse): void { + const exposed = headerValues(res, EXPOSE_HEADERS) + .flatMap(value => value.split(',')) + .map(name => name.trim()) + .filter(Boolean); + const links = headerValues(res, 'Link'); + + res.setHeader('Deprecation', `@${BFF_DEPRECATION_TIMESTAMP}`); + + if (!links.includes(DEPRECATION_LINK)) { + res.setHeader('Link', [...links, DEPRECATION_LINK].join(', ')); + } + + res.setHeader(EXPOSE_HEADERS, [...new Set([...exposed, ...DEPRECATION_HEADERS])].join(', ')); +} + +function markDeprecatedOnWriteHead(res: ServerResponse): void { + const { writeHead } = res; + + res.writeHead = ((...args: Parameters) => { + markDeprecated(res); + + return writeHead.apply(res, args); + }) as typeof writeHead; +} + +export function createBffAliasCallback(handle: HttpCallback, logger: Logger): HttpCallback { + let lastWarnedAt: number | null = null; + + return (req, res, next) => { + const now = Date.now(); + + if (lastWarnedAt === null || now - lastWarnedAt >= BFF_DEPRECATION_LOG_INTERVAL_MS) { + lastWarnedAt = now; + logger( + 'Warn', + `[BFF] ${BFF_PREFIX}/* is deprecated and was called: use addGateway({ api }), ` + + `which serves the same routes under /api (/api by default). ` + + `See ${BFF_DEPRECATION_LINK}`, + ); + } + + const aliasReq = req as typeof req & { originalUrl?: string }; + aliasReq.originalUrl ??= req.url; + req.url = stripBffPrefix(req.url ?? BFF_PREFIX); + markDeprecatedOnWriteHead(res); + handle(req, res, next); + }; +} + /** * Whether an MCP mount path would land inside `/bff`. The MCP server normalizes its `basePath` * (trim, leading slash, collapsed and stripped trailing slashes) before deriving diff --git a/packages/agent/src/embedded-bff.ts b/packages/agent/src/embedded-bff.ts index 98def5413d..3d4d006b1c 100644 --- a/packages/agent/src/embedded-bff.ts +++ b/packages/agent/src/embedded-bff.ts @@ -1,7 +1,14 @@ import type { AgentOptionsWithDefaults, BffEmbedOptions, HttpCallback } from './types'; -import type { AgentDispatcher, BFFConfig, Bff } from '@forestadmin/agent-bff'; +import type { AgentDispatcher, BFFConfig, Bff, claimsBffPath } from '@forestadmin/agent-bff'; -import { BFF_PREFIX, stripBffPrefix } from './bff-routes'; +const BFF_PACKAGE = '@forestadmin/agent-bff'; + +export type EmbeddedBffMount = { + prefix: string; + name?: string; + requiredVersion?: string; + skipIpWhitelistWarning?: boolean; +}; /** How long `stop()` waits for the activity-log writes no connection holds. */ export const SHUTDOWN_TIMEOUT_MS = 10_000; @@ -30,20 +37,25 @@ function formatLog(message: string, context?: Record): string { /** * Owns the lifecycle of a BFF embedded in the agent process: configuration, dynamic loading of the - * optional package, build, and the request handler the agent mounts at `/bff`. The agent only wires - * the callback and delegates start/stop. + * optional package, build, and the request handler the agent mounts under its prefix. The agent + * only wires the callback and delegates start/stop. */ export default class EmbeddedBff { private config: BFFConfig | null = null; private bff: Bff | null = null; private stopped = false; + private claimsBffPath: typeof claimsBffPath | null = null; constructor( private readonly options: AgentOptionsWithDefaults, private readonly embedOptions: BffEmbedOptions, - private readonly name = 'The embedded BFF', + private readonly mount: EmbeddedBffMount, ) {} + private get name(): string { + return this.mount.name ?? 'The embedded BFF'; + } + /** * Load the package and validate the options the caller handed to `addBff()`. Called before the * agent mounts anything: everything checked here is caller input, and `parseConfig` throws on a @@ -51,9 +63,13 @@ export default class EmbeddedBff { * where failing after mount() would leave the host with a live `/forest` and a bricked `/bff`. */ async prepare(): Promise { - const { parseConfig, IN_PROCESS_AGENT_URL } = await this.importPackage(); + const bffPackage = await this.importPackage(); + const { parseConfig, IN_PROCESS_AGENT_URL } = bffPackage; const { embedOptions } = this; + this.assertRequiredVersion(bffPackage.version); + this.claimsBffPath = bffPackage.claimsBffPath; + this.config = parseConfig({ FOREST_AUTH_SECRET: this.options.authSecret, FOREST_ENV_SECRET: this.options.envSecret, @@ -72,6 +88,23 @@ export default class EmbeddedBff { }); } + private assertRequiredVersion(installed: string): void { + const { requiredVersion } = this.mount; + + if (!requiredVersion || installed === requiredVersion) return; + + throw new Error( + `addGateway({ api }) requires ${BFF_PACKAGE} ${requiredVersion}, found ${installed}. ` + + `Install it with \`npm install ${BFF_PACKAGE}@${requiredVersion}\`.`, + ); + } + + claims(pathname: string): boolean { + if (!this.claimsBffPath) return false; + + return this.claimsBffPath(pathname, { docs: this.embedOptions.openapiEnabled ?? false }); + } + /** * Build the BFF. Called from agent.start() after mount(), because the dispatcher only reaches the * agent's own stack once that stack is mounted. @@ -88,7 +121,7 @@ export default class EmbeddedBff { const bff = await buildBff({ config: this.config as BFFConfig, dispatcher, - basePath: BFF_PREFIX, + basePath: this.mount.prefix, // Counters are the schema cache's and the action-endpoint resolver's only channel — they take // no logger — and every one of them reports a failure, so they go to the host's logs. Gauges // do not: they are periodic cache sizes, and the default console sink reports them at Info, @@ -109,9 +142,9 @@ export default class EmbeddedBff { this.bff = bff; - this.options.logger('Info', formatLog(`Embedded BFF mounted on ${BFF_PREFIX}`)); + this.options.logger('Info', formatLog(`Embedded BFF mounted on ${this.mount.prefix}`)); - await this.warnIfExemptFromIpWhitelist(); + if (!this.mount.skipIpWhitelistWarning) await this.warnIfExemptFromIpWhitelist(); } /** @@ -138,7 +171,7 @@ export default class EmbeddedBff { 'Warn', formatLog( `The IP whitelist is enabled for this environment, but requests served under ` + - `${BFF_PREFIX} are not subject to it: they reach the agent in-process, which the ` + + `${this.mount.prefix} are not subject to it: they reach the agent in-process, which the ` + `whitelist exempts as a trusted loopback caller. A resolved API key or a valid OAuth ` + `session is still required.`, ), @@ -193,8 +226,10 @@ export default class EmbeddedBff { } /** - * The callback the agent registers at `/bff`. Answers 503 rather than falling through while the - * BFF is not serving: a 404 from the host would read as "wrong url" instead of "not available". + * The callback the agent registers under the mount prefix, handed requests already stripped of + * it: terminal, it never strips nor calls `next`. Answers 503 rather than falling through while + * the BFF is not serving: a 404 from the host would read as "wrong url" instead of "not + * available". * Boot and shutdown are told apart, because a probe should wait on the first and drain on the * second. */ @@ -212,11 +247,6 @@ export default class EmbeddedBff { return; } - // The BFF knows nothing of the prefix it is served under. `originalUrl` is claimed before the - // rewrite so a host logger reading it still reports the url the client asked for. - const bffReq = req as typeof req & { originalUrl?: string }; - bffReq.originalUrl ??= bffReq.url; - req.url = stripBffPrefix(req.url ?? BFF_PREFIX); this.bff.callback(req, res); }; diff --git a/packages/agent/src/framework-mounter.ts b/packages/agent/src/framework-mounter.ts index 6ec461fe5f..e16111d334 100644 --- a/packages/agent/src/framework-mounter.ts +++ b/packages/agent/src/framework-mounter.ts @@ -52,6 +52,10 @@ export default class FrameworkMounter { } /** Serve an embedded BFF at `/bff`. Pass null to stop answering there. */ + protected setGatewayCallback(handler: RootHandler): void { + this.rootMiddleware.set('gateway', handler); + } + protected setBffCallback(callback: HttpCallback | null): void { this.rootMiddleware.set('bff', callback && { callback, matches: isBffRoute }); } diff --git a/packages/agent/src/gateway.ts b/packages/agent/src/gateway.ts index 6da358f807..4e33d86f4f 100644 --- a/packages/agent/src/gateway.ts +++ b/packages/agent/src/gateway.ts @@ -1,4 +1,43 @@ -import type { BffEmbedOptions, GatewayOptions, McpEmbedOptions } from './types'; +import type { BffEmbedOptions, GatewayOptions, McpEmbedOptions, RootHandler } from './types'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-dynamic-require, global-require +const { peerDependencies } = require('../package.json') as { + peerDependencies: Record; +}; + +export const AGENT_BFF_PEER_VERSION = peerDependencies['@forestadmin/agent-bff']; + +const isWithin = (path: string, root: string) => path === root || path.startsWith(`${root}/`); + +const HTTP_SERVICE_UNAVAILABLE = 503; + +const MCP_UNAVAILABLE = { + starting: 'The MCP server is not started yet.', + stopped: 'The MCP server was stopped with the agent.', +}; + +export function mcpUnavailable(basePath: string, state: keyof typeof MCP_UNAVAILABLE): RootHandler { + const mcpPath = `${basePath}/mcp`; + const oauthPrefix = `${basePath}/oauth/`; + + return { + matches: url => { + const [pathname] = url.split(/[?#]/, 1); + + return isWithin(pathname, mcpPath) || pathname.startsWith(oauthPrefix); + }, + callback: (_req, res) => { + res.statusCode = HTTP_SERVICE_UNAVAILABLE; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + error: 'temporarily_unavailable', + error_description: MCP_UNAVAILABLE[state], + }), + ); + }, + }; +} export type GatewayMcpOptions = Omit; @@ -26,8 +65,6 @@ export function resolveGatewayServices({ mcp, api }: GatewayOptions): GatewaySer return { mcp: mcpOptions, api: resolveService(api) }; } -const isWithin = (path: string, root: string) => path === root || path.startsWith(`${root}/`); - export function assertNoGatewayOverlap( basePath: string, forestPath: string, @@ -69,17 +106,12 @@ export function describeGatewayRoutes(basePath: string, { mcp, api }: GatewaySer const lines: string[] = []; if (mcp) { - const discovery = basePath - ? [ - `/.well-known/oauth-authorization-server${basePath}`, - `/.well-known/oauth-protected-resource${basePath}/mcp`, - ] - : ['/.well-known/*']; const routes = [ `${basePath}/mcp`, ...(servesInMemoryUploads(mcp) ? [`${basePath}/mcp/uploads`] : []), `${basePath}/oauth/*`, - ...discovery, + `/.well-known/oauth-authorization-server${basePath}`, + `/.well-known/oauth-protected-resource${basePath}/mcp`, ]; lines.push(`[Gateway] MCP: ${routes.join(', ')}`); diff --git a/packages/agent/src/utils/express-to-koa.ts b/packages/agent/src/utils/express-to-koa.ts index b2f0d686e9..61e7b4a88c 100644 --- a/packages/agent/src/utils/express-to-koa.ts +++ b/packages/agent/src/utils/express-to-koa.ts @@ -38,6 +38,8 @@ export default function expressToKoa( return; } + (ctx.req as typeof ctx.req & { originalUrl?: string }).originalUrl ??= ctx.originalUrl; + // Let Express handle the request const handled = await new Promise(resolve => { // Tell Koa not to respond - Express will handle it diff --git a/packages/agent/test/agent-gateway-wiring.test.ts b/packages/agent/test/agent-gateway-wiring.test.ts new file mode 100644 index 0000000000..2b1b48879b --- /dev/null +++ b/packages/agent/test/agent-gateway-wiring.test.ts @@ -0,0 +1,628 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { IncomingMessage, ServerResponse } from 'http'; + +import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; +import * as McpServer from '@forestadmin/mcp-server'; +import express from 'express'; +import Koa from 'koa'; +import request from 'supertest'; + +import * as factories from './__factories__'; +import Agent from '../src/agent'; +import { BFF_DEPRECATION_LINK, BFF_DEPRECATION_LOG_INTERVAL_MS } from '../src/bff-routes'; + +const PEER_VERSION: string = + jest.requireActual('../package.json').peerDependencies['@forestadmin/agent-bff']; + +const mockMakeRoutes = jest.fn(); + +jest.mock('../src/routes', () => ({ + __esModule: true, + default: (...args) => mockMakeRoutes(...args), +})); + +jest.mock('@forestadmin/datasource-customizer'); + +const mockBuildBff = jest.fn(); +let mockBffVersion = ''; + +jest.mock('@forestadmin/agent-bff', () => ({ + __esModule: true, + IN_PROCESS_AGENT_URL: 'http://in-process.agent', + parseConfig: () => ({}), + buildBff: (options: unknown) => mockBuildBff(options), + claimsBffPath: (...args: unknown[]) => + jest.requireActual('@forestadmin/agent-bff').claimsBffPath(...args), + get version() { + return mockBffVersion; + }, +})); + +const mockExecutorStart = jest.fn(); + +jest.mock('@forestadmin/workflow-executor', () => ({ + __esModule: true, + buildInMemoryExecutor: () => ({ start: mockExecutorStart, stop: jest.fn(), state: 'idle' }), +})); + +type Req = IncomingMessage & { originalUrl?: string }; + +function echo(service: string) { + return (req: Req, res: ServerResponse) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ service, url: req.url, originalUrl: req.originalUrl })); + }; +} + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise(done => { + resolve = done; + }); + + return { promise, resolve }; +} + +async function until(condition: () => boolean) { + while (!condition()) { + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => { + setImmediate(resolve); + }); + } +} + +let mcpServerSpy: jest.SpyInstance; +let mockGetHttpCallback: jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockBffVersion = PEER_VERSION; + + mockMakeRoutes.mockReturnValue([{ setupRoutes: jest.fn(), bootstrap: jest.fn() }]); + mockBuildBff.mockResolvedValue({ callback: echo('api'), invalidate: jest.fn() }); + jest + .mocked(DataSourceCustomizer.prototype.getDataSource) + .mockResolvedValue(factories.dataSource.build()); + + mockGetHttpCallback = jest.fn().mockResolvedValue(echo('mcp')); + mcpServerSpy = jest + .spyOn(McpServer, 'ForestMCPServer') + .mockImplementation(() => ({ getHttpCallback: mockGetHttpCallback } as any)); +}); + +afterEach(() => mcpServerSpy.mockRestore()); + +function buildAgent(overrides: Record = {}) { + const logger = jest.fn(); + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ logger, skipSchemaUpdate: true, ...overrides }), + ); + + return { agent, logger }; +} + +function onExpress(agent: Agent) { + const app = express(); + agent.mountOnExpress(app); + + return app; +} + +function onKoa(agent: Agent) { + const app = new Koa(); + agent.mountOnKoa(app); + + return app.callback(); +} + +describe.each(['', '/ai'])('addGateway() routing with basePath "%s"', basePath => { + async function startedGateway() { + const { agent } = buildAgent(); + agent.addGateway({ basePath, mcp: true, api: {} }); + const app = onExpress(agent); + await agent.start(); + + return { agent, app }; + } + + it('should serve the MCP and the stripped API health side by side', async () => { + const { agent, app } = await startedGateway(); + + expect((await request(app).get(`${basePath}/mcp`)).body).toMatchObject({ service: 'mcp' }); + expect((await request(app).get(`${basePath}/api/health`)).body).toEqual({ + service: 'api', + url: '/health', + originalUrl: `${basePath}/api/health`, + }); + await agent.stop(); + }); + + it('should hand the API its data path and keep the probe query', async () => { + const { agent, app } = await startedGateway(); + + const list = await request(app).post(`${basePath}/api/agent/v1/books/list`); + const probe = await request(app).get(`${basePath}/api/health?probe=1`); + + expect(list.body).toMatchObject({ service: 'api', url: '/agent/v1/books/list' }); + expect(probe.body).toMatchObject({ service: 'api', url: '/health?probe=1' }); + await agent.stop(); + }); + + it('should leave unclaimed API paths to the host', async () => { + const { agent, app } = await startedGateway(); + + expect((await request(app).post(`${basePath}/api/oauth/token`)).status).toBe(404); + expect((await request(app).get(`${basePath}/api/unknown`)).status).toBe(404); + expect((await request(app).get(`${basePath}/api/docs`)).status).toBe(404); + await agent.stop(); + }); + + it('should dispatch the shared OAuth routes on the service parameter', async () => { + const { agent, app } = await startedGateway(); + + const api = await request(app).post(`${basePath}/oauth/token?service=api`); + const mcp = await request(app).post(`${basePath}/oauth/token`); + + expect(api.body).toEqual({ + service: 'api', + url: '/oauth/token?service=api', + originalUrl: `${basePath}/oauth/token?service=api`, + }); + expect(mcp.body).toMatchObject({ service: 'mcp', url: `${basePath}/oauth/token` }); + await agent.stop(); + }); + + it('should serve the new MCP once restart() has rebuilt it', async () => { + const { agent, app } = await startedGateway(); + mockGetHttpCallback.mockResolvedValue(echo('mcp-restarted')); + + await agent.restart(); + + expect((await request(app).get(`${basePath}/mcp`)).body.service).toBe('mcp-restarted'); + await agent.stop(); + }); + + it('should keep serving the previous MCP while restart() builds the new one', async () => { + const { agent, app } = await startedGateway(); + const rebuilt = deferred>(); + mockGetHttpCallback.mockReturnValue(rebuilt.promise); + + const restarting = agent.restart(); + await until(() => mockGetHttpCallback.mock.calls.length === 2); + + expect((await request(app).get(`${basePath}/mcp`)).body.service).toBe('mcp'); + rebuilt.resolve(echo('mcp-restarted')); + await restarting; + expect((await request(app).get(`${basePath}/mcp`)).body.service).toBe('mcp-restarted'); + await agent.stop(); + }); +}); + +describe('addGateway() with a basePath', () => { + it('should leave the root /api paths to the host', async () => { + const { agent } = buildAgent(); + agent.addGateway({ basePath: '/ai', api: {} }); + const app = onExpress(agent); + await agent.start(); + + expect((await request(app).post('/ai/api/agent/v1/books/list')).body.service).toBe('api'); + expect((await request(app).post('/api/agent/v1/books/list')).status).toBe(404); + expect((await request(app).get('/api/health')).status).toBe(404); + await agent.stop(); + }); +}); + +describe('the API callback behind the switch', () => { + it('should serve an already stripped request as-is, without ever calling next', async () => { + const { agent } = buildAgent(); + agent.addGateway({ api: {} }); + const app = onExpress(agent); + const reachedHost: string[] = []; + app.use((req: Req, res: ServerResponse) => { + reachedHost.push(req.url ?? ''); + res.end(); + }); + await agent.start(); + + const response = await request(app).get('/api/health'); + + expect(response.body).toEqual({ service: 'api', url: '/health', originalUrl: '/api/health' }); + expect(reachedHost).toEqual([]); + await agent.stop(); + }); +}); + +describe.each([ + ['express', onExpress], + ['koa', onKoa], +])('addGateway() lifecycle on %s', (_name, mount) => { + it('should leave every Gateway path to the host before start()', async () => { + const { agent } = buildAgent(); + agent.addGateway({ mcp: true, api: {} }); + const app = mount(agent); + + expect((await request(app).get('/mcp')).status).toBe(404); + expect((await request(app).get('/api/health')).status).toBe(404); + }); + + it('should answer 503 on both services between prepare() and their build', async () => { + const dataSource = deferred>(); + const getDataSource = jest.mocked(DataSourceCustomizer.prototype.getDataSource); + getDataSource.mockReturnValue(dataSource.promise); + const { agent } = buildAgent(); + agent.addGateway({ mcp: true, api: {} }); + const app = mount(agent); + + const starting = agent.start(); + await until(() => getDataSource.mock.calls.length > 0); + + const api = await request(app).get('/api/health'); + const mcp = await request(app).post('/mcp'); + const oauth = await request(app).post('/oauth/token'); + + expect(api.status).toBe(503); + expect(api.body.error.type).toBe('bff_not_started'); + expect([mcp.status, oauth.status]).toEqual([503, 503]); + expect(mcp.body).toEqual({ + error: 'temporarily_unavailable', + error_description: 'The MCP server is not started yet.', + }); + dataSource.resolve(factories.dataSource.build()); + await starting; + await agent.stop(); + }); + + it('should answer 503 on both services once stopped', async () => { + const { agent } = buildAgent(); + agent.addGateway({ mcp: true, api: {} }); + const app = mount(agent); + await agent.start(); + + await agent.stop(); + + const api = await request(app).get('/api/health'); + const mcp = await request(app).post('/mcp'); + expect(api.status).toBe(503); + expect(api.body.error.type).toBe('bff_stopped'); + expect(mcp.status).toBe(503); + expect(mcp.body.error_description).toBe('The MCP server was stopped with the agent.'); + }); + + it('should report the stopped state when stop() lands while start() prepares', async () => { + const getDataSource = jest.mocked(DataSourceCustomizer.prototype.getDataSource); + const { agent } = buildAgent(); + agent.addGateway({ mcp: true, api: {} }); + const app = mount(agent); + + const starting = agent.start(); + await agent.stop(); + await starting; + + const mcp = await request(app).post('/oauth/token'); + expect(mcp.status).toBe(503); + expect(mcp.body.error_description).toBe('The MCP server was stopped with the agent.'); + expect((await request(app).get('/api/health')).body.error.type).toBe('bff_stopped'); + expect(getDataSource).not.toHaveBeenCalled(); + }); + + it('should stay stopped when stop() lands before start() has mounted', async () => { + const dataSource = deferred>(); + const getDataSource = jest.mocked(DataSourceCustomizer.prototype.getDataSource); + getDataSource.mockReturnValue(dataSource.promise); + const forestAdminClient = factories.forestAdminClient.build(); + const { agent } = buildAgent({ forestAdminClient }); + agent.addGateway({ mcp: true, api: {} }); + const app = mount(agent); + + const starting = agent.start(); + await until(() => getDataSource.mock.calls.length > 0); + await agent.stop(); + dataSource.resolve(factories.dataSource.build()); + await starting; + + expect((await request(app).get('/api/health')).body.error.type).toBe('bff_stopped'); + expect((await request(app).post('/mcp')).status).toBe(503); + expect(mockBuildBff).not.toHaveBeenCalled(); + expect(forestAdminClient.subscribeToServerEvents).not.toHaveBeenCalled(); + }); + + it('should answer stopped on the MCP when stop() lands while the gateway is preparing', async () => { + const { agent } = buildAgent(); + agent.addGateway({ mcp: true }); + const app = mount(agent); + const preparing = deferred(); + const prepare = (agent as any).prepareGateway.bind(agent); + jest.spyOn(agent as any, 'prepareGateway').mockImplementation(async () => { + await preparing.promise; + await prepare(); + }); + const prepareCalls = jest.mocked((agent as any).prepareGateway).mock.calls; + + const starting = agent.start(); + await until(() => prepareCalls.length > 0); + await agent.stop(); + preparing.resolve(); + await starting; + + const mcp = await request(app).post('/mcp'); + expect(mcp.status).toBe(503); + expect(mcp.body.error_description).toBe('The MCP server was stopped with the agent.'); + }); + + it('should not build the API when stop() lands after mount() but before its build', async () => { + const executorStart = deferred(); + mockExecutorStart.mockReturnValue(executorStart.promise); + const { agent } = buildAgent(); + agent + .addGateway({ mcp: true, api: {} }) + .addWorkflowExecutor({ agentUrl: 'http://localhost:3310', inMemory: true }); + const app = mount(agent); + + const starting = agent.start(); + await until(() => mockExecutorStart.mock.calls.length > 0); + await agent.stop(); + executorStart.resolve(); + await starting; + + expect((await request(app).get('/api/health')).body.error.type).toBe('bff_stopped'); + expect((await request(app).post('/mcp')).status).toBe(503); + expect(mockBuildBff).not.toHaveBeenCalled(); + }); + + it('should keep the MCP stopped when a restart in flight completes after stop()', async () => { + const { agent } = buildAgent(); + agent.addGateway({ mcp: true, api: {} }); + const app = mount(agent); + let routerVersion = 0; + mockMakeRoutes.mockImplementation(() => { + routerVersion += 1; + const version = routerVersion; + + return [ + { + bootstrap: jest.fn(), + setupRoutes: (router: { get: (...args: unknown[]) => void }) => + router.get('/router-version', (ctx: { body: unknown }) => { + ctx.body = { version }; + }), + }, + ]; + }); + await agent.start(); + const rebuilt = deferred>(); + mockGetHttpCallback.mockReturnValue(rebuilt.promise); + + const restarting = agent.restart(); + await until(() => mockGetHttpCallback.mock.calls.length === 2); + await agent.stop(); + rebuilt.resolve(echo('mcp-restarted')); + await restarting; + + expect(routerVersion).toBe(2); + expect((await request(app).get('/prefix/forest/router-version')).body).toEqual({ version: 1 }); + expect((await request(app).post('/mcp')).status).toBe(503); + expect((await request(app).get('/api/health')).status).toBe(503); + }); + + it('should leave /.well-known to the host in every window the MCP is not built', async () => { + const dataSource = deferred>(); + const getDataSource = jest.mocked(DataSourceCustomizer.prototype.getDataSource); + getDataSource.mockReturnValue(dataSource.promise); + const { agent } = buildAgent(); + agent.addGateway({ mcp: true, api: {} }); + const app = mount(agent); + const wellKnown = async () => (await request(app).get('/.well-known/acme-challenge/x')).status; + + const before = await wellKnown(); + const starting = agent.start(); + await until(() => getDataSource.mock.calls.length > 0); + const during = await wellKnown(); + dataSource.resolve(factories.dataSource.build()); + await starting; + await agent.stop(); + + expect([before, during, await wellKnown()]).toEqual([404, 404, 404]); + }); + + it('should keep answering 503 when start() fails before mount()', async () => { + mockGetHttpCallback.mockRejectedValue(new Error('MCP down')); + const { agent } = buildAgent(); + agent.addGateway({ mcp: true, api: {} }); + const app = mount(agent); + + await expect(agent.start()).rejects.toThrow('MCP down'); + + expect((await request(app).get('/api/health')).status).toBe(503); + expect((await request(app).post('/mcp')).status).toBe(503); + expect((await request(app).get('/.well-known/oauth-authorization-server')).status).toBe(404); + }); +}); + +describe('addGateway() checks at start()', () => { + it('should refuse another agent-bff version, naming both', async () => { + mockBffVersion = '0.0.1'; + const { agent } = buildAgent(); + agent.addGateway({ api: {} }); + + await expect(agent.start()).rejects.toThrow( + `addGateway({ api }) requires @forestadmin/agent-bff ${PEER_VERSION}, found 0.0.1. ` + + `Install it with \`npm install @forestadmin/agent-bff@${PEER_VERSION}\`.`, + ); + }); + + it('should leave the host /api routes alone when stop() follows a failed start()', async () => { + mockBffVersion = '0.0.1'; + const { agent } = buildAgent(); + agent.addGateway({ api: {} }); + const app = onExpress(agent); + app.get('/api/users', (_req, res) => { + res.send('host users'); + }); + + await expect(agent.start()).rejects.toThrow('requires @forestadmin/agent-bff'); + await agent.stop(); + + const response = await request(app).get('/api/users'); + expect(response.status).toBe(200); + expect(response.text).toBe('host users'); + }); + + it('should leave addBff() free of the version check', async () => { + mockBffVersion = '0.0.1'; + const { agent } = buildAgent(); + agent.addBff(); + + await expect(agent.start()).resolves.toBeUndefined(); + await agent.stop(); + }); + + it('should warn once about the IP whitelist, and not again on restart', async () => { + const getIpWhitelistConfiguration = jest.fn().mockResolvedValue({ isFeatureEnabled: true }); + const forestAdminClient = factories.forestAdminClient.build({ getIpWhitelistConfiguration }); + const { agent, logger } = buildAgent({ forestAdminClient }); + agent.addGateway({ mcp: true }); + + await agent.start(); + await agent.restart(); + + const warnings = logger.mock.calls.filter(([, message]) => /IP whitelist/.test(message)); + expect(warnings).toEqual([['Warn', expect.stringMatching(/^\[MCP\] The IP whitelist/)]]); + expect(warnings[0][1]).not.toContain('Gateway API'); + }); + + it('should warn once with both services on, naming the API routes in the MCP warning', async () => { + const getIpWhitelistConfiguration = jest.fn().mockResolvedValue({ isFeatureEnabled: true }); + const forestAdminClient = factories.forestAdminClient.build({ getIpWhitelistConfiguration }); + const { agent, logger } = buildAgent({ forestAdminClient }); + agent.addGateway({ mcp: true, api: {} }); + + await agent.start(); + + const warnings = logger.mock.calls.filter(([, message]) => /IP whitelist/.test(message)); + expect(warnings).toEqual([['Warn', expect.stringMatching(/^\[MCP\] The IP whitelist/)]]); + expect(warnings[0][1]).toContain('The Gateway API on /api/* escapes it too'); + await agent.stop(); + }); + + it('should log the API mount on its Gateway prefix', async () => { + const { agent, logger } = buildAgent(); + agent.addGateway({ basePath: '/ai', api: {} }); + + await agent.start(); + + expect(logger).toHaveBeenCalledWith('Info', '[BFF] Embedded BFF mounted on /ai/api'); + expect(mockBuildBff).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/ai/api' })); + await agent.stop(); + }); +}); + +describe('addBff() deprecated alias', () => { + it('should strip /bff and mark every response deprecated', async () => { + const { agent } = buildAgent(); + agent.addBff(); + const app = onExpress(agent); + await agent.start(); + + const response = await request(app).get('/bff/health'); + + expect(response.body).toEqual({ service: 'api', url: '/health', originalUrl: '/bff/health' }); + expect(response.headers.deprecation).toBe('@1791158400'); + expect(response.headers.link).toBe(`<${BFF_DEPRECATION_LINK}>; rel="deprecation"`); + expect(response.headers['access-control-expose-headers']).toBe('Deprecation, Link'); + expect((await request(app).get('/api/health')).status).toBe(404); + await agent.stop(); + }); + + it('should append its Link and exposed headers to the ones the host already set', async () => { + const { agent } = buildAgent(); + agent.addBff(); + const app = express(); + app.use((_req, res, next) => { + res.setHeader('Link', '; rel="preload"'); + res.setHeader('Access-Control-Expose-Headers', 'X-Request-Id, Link'); + next(); + }); + agent.mountOnExpress(app); + await agent.start(); + + const response = await request(app).get('/bff/health'); + + expect(response.headers.link).toBe( + `; rel="preload", <${BFF_DEPRECATION_LINK}>; rel="deprecation"`, + ); + expect(response.headers['access-control-expose-headers']).toBe( + 'X-Request-Id, Link, Deprecation', + ); + await agent.stop(); + }); + + it('should mark a 503 answered before start() deprecated', async () => { + const { agent } = buildAgent(); + agent.addBff(); + const app = onExpress(agent); + + const response = await request(app).get('/bff/health'); + + expect(response.status).toBe(503); + expect(response.headers.deprecation).toBe('@1791158400'); + }); + + it('should keep the deprecation headers when the BFF clears its headers on an error', async () => { + mockBuildBff.mockResolvedValue({ + callback: (_req: Req, res: ServerResponse) => { + res.getHeaderNames().forEach(name => res.removeHeader(name)); + res.statusCode = 500; + res.end(); + }, + invalidate: jest.fn(), + }); + const { agent } = buildAgent(); + agent.addBff(); + const app = onExpress(agent); + await agent.start(); + + const response = await request(app).get('/bff/health'); + + expect(response.status).toBe(500); + expect(response.headers.deprecation).toBe('@1791158400'); + expect(response.headers.link).toBe(`<${BFF_DEPRECATION_LINK}>; rel="deprecation"`); + await agent.stop(); + }); + + it('should keep its throttle window across a restart', async () => { + const { agent, logger } = buildAgent(); + agent.addBff(); + const app = onExpress(agent); + await agent.start(); + + await request(app).get('/bff/health'); + await agent.restart(); + await request(app).get('/bff/health'); + + const deprecationLogs = logger.mock.calls.filter(([, message]) => + /\/bff\/\* is deprecated/.test(message), + ); + expect(deprecationLogs).toHaveLength(1); + await agent.stop(); + }); + + it('should throttle the deprecation log to one line per interval', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(0); + const { agent, logger } = buildAgent(); + agent.addBff(); + const app = onExpress(agent); + await agent.start(); + const deprecationLogs = () => + logger.mock.calls.filter(([, message]) => /\/bff\/\* is deprecated/.test(message)); + + await request(app).get('/bff/health'); + await request(app).get('/bff/health'); + now.mockReturnValue(BFF_DEPRECATION_LOG_INTERVAL_MS); + await request(app).get('/bff/health'); + + expect(deprecationLogs()).toHaveLength(2); + now.mockRestore(); + await agent.stop(); + }); +}); diff --git a/packages/agent/test/agent-gateway.test.ts b/packages/agent/test/agent-gateway.test.ts index db6462f67c..4214edb305 100644 --- a/packages/agent/test/agent-gateway.test.ts +++ b/packages/agent/test/agent-gateway.test.ts @@ -26,6 +26,8 @@ jest.mock('@forestadmin/agent-bff', () => ({ IN_PROCESS_AGENT_URL: 'http://in-process.agent', parseConfig: (env: unknown) => mockParseConfig(env), buildBff: (options: unknown) => mockBuildBff(options), + claimsBffPath: () => true, + version: jest.requireActual('../package.json').peerDependencies['@forestadmin/agent-bff'], })); let mcpServerSpy: jest.SpyInstance; @@ -118,7 +120,7 @@ describe('Agent.addGateway', () => { await agent.start(); expect(gatewayLog(logger, 'MCP')).toBe( - '[Gateway] MCP: /mcp, /mcp/uploads, /oauth/*, /.well-known/*', + '[Gateway] MCP: /mcp, /mcp/uploads, /oauth/*, /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/mcp', ); expect(gatewayLog(logger, 'API')).toBe( '[Gateway] API: /api/agent/*, /api/health, /oauth/*?service=api', @@ -172,7 +174,9 @@ describe('Agent.addGateway', () => { await agent.start(); - expect(gatewayLog(logger, 'MCP')).toBe('[Gateway] MCP: /mcp, /oauth/*, /.well-known/*'); + expect(gatewayLog(logger, 'MCP')).toBe( + '[Gateway] MCP: /mcp, /oauth/*, /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/mcp', + ); }); it('should log only the enabled service', async () => { @@ -182,7 +186,7 @@ describe('Agent.addGateway', () => { await agent.start(); expect(gatewayLog(logger, 'MCP')).toBe( - '[Gateway] MCP: /mcp, /mcp/uploads, /oauth/*, /.well-known/*', + '[Gateway] MCP: /mcp, /mcp/uploads, /oauth/*, /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/mcp', ); expect(() => gatewayLog(logger, 'API')).toThrow(); }); @@ -233,18 +237,6 @@ describe('Agent.addGateway', () => { expect(mcpServerSpy).not.toHaveBeenCalled(); }); - it('should leave every Gateway path to the host: the Gateway routing ships separately', async () => { - const { agent } = buildAgent(); - mockGetHttpCallback.mockResolvedValue(answeringWith('mcp')); - agent.addGateway({ mcp: true, api: {} }); - - const request = await startOnPort(agent); - - expect((await request('/mcp')).status).toBe(404); - expect((await request('/oauth/token')).status).toBe(404); - expect((await request('/api/health')).status).toBe(404); - }); - it('should surface the MCP domain-root rejection from start()', async () => { const { agent } = buildAgent(); const rejection = new Error( diff --git a/packages/agent/test/bff-routes.test.ts b/packages/agent/test/bff-routes.test.ts index 48021a3c47..1415a2667f 100644 --- a/packages/agent/test/bff-routes.test.ts +++ b/packages/agent/test/bff-routes.test.ts @@ -31,6 +31,13 @@ describe('stripBffPrefix', () => { it('should never yield an empty url, which Koa would read as malformed', () => { expect(stripBffPrefix('/bff')).not.toBe(''); }); + + it.each(['/health', '/agent/v1/books/list', '/oauth/token', '/docs', '/bffalo'])( + 'should leave %s untouched, so a stripped url is never stripped again', + url => { + expect(stripBffPrefix(url)).toBe(url); + }, + ); }); describe('collidesWithBff', () => { diff --git a/packages/agent/test/gateway/embedded-gateway.e2e.test.ts b/packages/agent/test/gateway/embedded-gateway.e2e.test.ts new file mode 100644 index 0000000000..1e1626c238 --- /dev/null +++ b/packages/agent/test/gateway/embedded-gateway.e2e.test.ts @@ -0,0 +1,451 @@ +import type { GatewayOptions } from '../../src/types'; + +import express from 'express'; +import Fastify from 'fastify'; +import jsonwebtoken from 'jsonwebtoken'; +import Koa from 'koa'; +import { tmpdir } from 'os'; +import path from 'path'; +import request from 'supertest'; + +import Agent from '../../src/agent'; +import { BFF_DEPRECATION_LINK } from '../../src/bff-routes'; +import MockForestServer from '../__helper__/mock-forest-server'; +import SearchDataSource from '../bff/fixtures/search-datasource'; + +const AUTH_SECRET = 'test-auth-secret-32-chars-min!!!'; +const ENV_SECRET = '0'.repeat(64); +const TOKEN_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64'); +const BOOT_TIMEOUT_MS = 30_000; +const ALLOWED_ORIGIN = 'https://my-app.com'; +const CLIENT_ID = 'test-client'; +const REDIRECT_URI = 'http://localhost:3000/callback'; +const CODE_VERIFIER = 'v'.repeat(43); +const CODE_CHALLENGE = 'c'.repeat(43); +const TIMEZONE = 'Europe/Paris'; +const LIST_BODY = { projection: ['id', 'title'] }; + +let codeCount = 0; + +const nextCode = () => { + codeCount += 1; + + return `forest-code-${codeCount}`; +}; + +const COLLECTION_PERMISSIONS = { + collection: { + browseEnabled: true, + readEnabled: true, + editEnabled: true, + addEnabled: true, + deleteEnabled: true, + exportEnabled: true, + }, + actions: {}, +}; + +const COLLECTIONS = [ + { + name: 'books', + fields: [ + { field: 'id', type: 'Number', isPrimaryKey: true }, + { field: 'title', type: 'String' }, + { field: 'authorId', type: 'Number' }, + ], + }, +]; + +function forestAccessToken(): string { + return jsonwebtoken.sign({ meta: { renderingId: 1 } }, 'forest-server-secret', { + expiresIn: '1h', + }); +} + +function startMockForestServer(): MockForestServer { + return new MockForestServer() + .get('/liana/v4/permissions/environment', { collections: { books: COLLECTION_PERMISSIONS } }) + .post('/oauth/token', () => ({ + access_token: forestAccessToken(), + refresh_token: 'forest-refresh-token', + })) + .get(/\/liana\/v2\/renderings\/\d+\/authorization/, { + data: { + id: '1', + attributes: { + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + teams: ['admin'], + role: 'admin', + permission_level: 'admin', + tags: [], + }, + }, + }) + .setupDefaultRoutes({ envSecret: ENV_SECRET, collections: COLLECTIONS }) + .setupSuperagentMock() + .setupFetchMock(); +} + +function buildAgent(name: string): Agent { + return new Agent({ + authSecret: AUTH_SECRET, + envSecret: ENV_SECRET, + forestServerUrl: 'https://api.forestadmin.com', + forestAppUrl: 'https://app.forestadmin.com', + isProduction: false, + schemaPath: path.join(tmpdir(), `.forestadmin-schema-gateway-${name}-${Date.now()}.json`), + logger: () => undefined, + }).addDataSource(async () => new SearchDataSource()); +} + +const API_OPTIONS = { + allowedOrigins: [ALLOWED_ORIGIN], + tokenEncryptionKey: TOKEN_ENCRYPTION_KEY, + openapiEnabled: true, +}; + +async function startOn(agent: Agent, hostRoutes?: (app: express.Express) => void) { + const app = express(); + agent.mountOnExpress(app); + hostRoutes?.(app); + await agent.start(); + + return app; +} + +function oauth(oauthBase: string, query = '') { + const separator = query ? '&' : '?'; + + return { + authorize: (app: express.Express) => + request(app).get(`${oauthBase}/authorize${query}`).query({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + code_challenge: CODE_CHALLENGE, + code_challenge_method: 'S256', + state: 'state-1', + }), + token: (app: express.Express, body: Record) => + request(app) + .post(`${oauthBase}/token${query}`) + .set('Content-Type', 'application/json') + .send({ client_id: CLIENT_ID, ...body }), + separator, + }; +} + +async function signIn(app: express.Express, oauthBase: string, query = ''): Promise { + const response = await oauth(oauthBase, query).token(app, { + grant_type: 'authorization_code', + code: nextCode(), + code_verifier: CODE_VERIFIER, + redirect_uri: REDIRECT_URI, + }); + + return response.body.access_token; +} + +function listBooks(app: express.Express, url: string, token: string) { + return request(app) + .post(url) + .set('Authorization', `Bearer ${token}`) + .set('X-Forest-Timezone', TIMEZONE) + .send(LIST_BODY); +} + +function preflight(app: express.Express, url: string) { + return request(app) + .options(url) + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST'); +} + +function corsHeaders(headers: Record) { + return Object.fromEntries( + Object.entries(headers).filter( + ([name]) => name.startsWith('access-control-') && name !== 'access-control-expose-headers', + ), + ); +} + +let mockForestServer: MockForestServer; +let legacy: Agent; +let legacyApp: express.Express; + +beforeAll(async () => { + mockForestServer = startMockForestServer(); + legacy = buildAgent('legacy').addBff(API_OPTIONS); + legacyApp = await startOn(legacy); +}, BOOT_TIMEOUT_MS); + +afterAll(async () => { + await legacy?.stop(); + mockForestServer?.restore(); +}); + +describe.each(['', '/ai'])('the embedded Gateway on basePath "%s"', basePath => { + const apiBase = `${basePath}/api`; + const apiOAuth = oauth(`${basePath}/oauth`, '?service=api'); + let agent: Agent; + let app: express.Express; + + beforeAll(async () => { + agent = buildAgent(`gateway${basePath.replace('/', '-')}`).addGateway({ + basePath, + mcp: true, + api: API_OPTIONS, + }); + app = await startOn(agent, host => { + host.post(`${apiBase}/oauth/token`, (_req, res) => { + res.send('host'); + }); + }); + }, BOOT_TIMEOUT_MS); + + afterAll(async () => { + await agent?.stop(); + }); + + it('should answer the MCP and the API health', async () => { + const mcp = await request(app).post(`${basePath}/mcp`); + const health = await request(app).get(`${apiBase}/health?probe=1`); + + expect(mcp.status).toBe(401); + expect(health.status).toBe(200); + expect(health.body).toMatchObject({ status: 'ok', configured: { oauth: true, openapi: true } }); + }); + + it('should list the records exactly as /bff does', async () => { + const gatewayToken = await signIn(app, `${basePath}/oauth`, '?service=api'); + const legacyToken = await signIn(legacyApp, '/bff/oauth'); + + const gateway = await listBooks(app, `${apiBase}/agent/v1/books/list`, gatewayToken); + const legacyList = await listBooks(legacyApp, '/bff/agent/v1/books/list', legacyToken); + + expect(gateway.status).toBe(200); + expect(gateway.body).toEqual(legacyList.body); + }); + + it('should redirect authorize to Forest, then exchange and refresh into API tokens', async () => { + const authorize = await apiOAuth.authorize(app); + const exchange = await apiOAuth.token(app, { + grant_type: 'authorization_code', + code: nextCode(), + code_verifier: CODE_VERIFIER, + redirect_uri: REDIRECT_URI, + }); + const refresh = await apiOAuth.token(app, { + grant_type: 'refresh_token', + refresh_token: exchange.body.refresh_token, + }); + + expect(authorize.status).toBe(302); + expect(authorize.headers.location).toMatch( + /^https:\/\/app\.forestadmin\.com\/oauth\/authorize\?/, + ); + expect(exchange.status).toBe(200); + expect(jsonwebtoken.verify(exchange.body.access_token, AUTH_SECRET)).toMatchObject({ + type: 'bff_access', + }); + expect(refresh.status).toBe(200); + expect(refresh.body.refresh_token).not.toBe(exchange.body.refresh_token); + }); + + it('should answer the API and OAuth preflights with the API CORS, as /bff does', async () => { + const api = await preflight(app, `${apiBase}/agent/v1/books/list`); + const apiOAuthPreflight = await preflight(app, `${basePath}/oauth/token?service=api`); + const legacyPreflight = await preflight(legacyApp, '/bff/agent/v1/books/list'); + + expect(api.status).toBe(legacyPreflight.status); + expect(corsHeaders(api.headers)).toEqual(corsHeaders(legacyPreflight.headers)); + expect(api.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN); + expect(apiOAuthPreflight.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN); + }); + + it('should announce its own base on the docs page, and /bff its own', async () => { + const page = await request(app).get(`${apiBase}/docs`); + const legacyPage = await request(legacyApp).get('/bff/docs'); + + expect(page.text).toContain(`${apiBase}/docs/redoc.standalone.js`); + expect(legacyPage.text).toContain('/bff/docs/redoc.standalone.js'); + }); + + it('should leave the unclaimed API paths and the agent routes to the host', async () => { + expect((await request(app).post(`${apiBase}/oauth/token`)).text).toBe('host'); + expect((await request(app).get(`${apiBase}/unknown`)).status).toBe(404); + expect((await request(app).get('/forest')).status).toBe(200); + }); + + it('should keep serving both services after a restart', async () => { + await agent.restart(); + const token = await signIn(app, `${basePath}/oauth`, '?service=api'); + + expect((await request(app).post(`${basePath}/mcp`)).status).toBe(401); + expect((await listBooks(app, `${apiBase}/agent/v1/books/list`, token)).status).toBe(200); + }); +}); + +describe('the embedded Gateway on a basePath', () => { + it( + 'should leave the root /api paths to the host', + async () => { + const agent = buildAgent('ai-only').addGateway({ basePath: '/ai', api: API_OPTIONS }); + const app = await startOn(agent); + + try { + expect((await request(app).get('/ai/api/health')).status).toBe(200); + expect((await request(app).get('/api/health')).status).toBe(404); + } finally { + await agent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); +}); + +describe('the embedded Gateway without openapiEnabled', () => { + it( + 'should leave a host /api/docs route to the host', + async () => { + const options: GatewayOptions = { api: { tokenEncryptionKey: TOKEN_ENCRYPTION_KEY } }; + const agent = buildAgent('no-docs').addGateway(options); + const app = await startOn(agent, host => { + host.get('/api/docs', (_req, res) => { + res.send('host docs'); + }); + }); + + try { + expect((await request(app).get('/api/docs')).text).toBe('host docs'); + } finally { + await agent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); +}); + +describe('the embedded Gateway under a host sub-path', () => { + it( + 'should keep the host prefix in the document and the docs links', + async () => { + const agent = buildAgent('sub-path').addGateway({ basePath: '/ai', api: API_OPTIONS }); + const mounted = express(); + agent.mountOnExpress(mounted); + const hostApp = express(); + hostApp.use('/host', mounted); + await agent.start(); + + try { + const token = await signIn(hostApp, '/host/ai/oauth', '?service=api'); + const document = await request(hostApp) + .get('/host/ai/api/agent/openapi.json') + .set('Authorization', `Bearer ${token}`); + const page = await request(hostApp).get('/host/ai/api/docs'); + + expect(document.body.servers[0].url).toBe('/host/ai/api'); + expect(page.text).toContain('/host/ai/api/agent/openapi.json'); + expect(page.text).toContain('/host/ai/api/docs/redoc.standalone.js'); + } finally { + await agent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); +}); + +describe('the deprecated /bff alias', () => { + it('should mark its 404 and its preflight deprecated too', async () => { + const notFound = await request(legacyApp).get('/bff/unknown'); + const preflightResponse = await preflight(legacyApp, '/bff/agent/v1/books/list'); + + expect(notFound.status).toBe(404); + [notFound, preflightResponse].forEach(response => { + expect(response.headers.deprecation).toBe('@1791158400'); + expect(response.headers.link).toBe(`<${BFF_DEPRECATION_LINK}>; rel="deprecation"`); + }); + }); +}); + +describe('the embedded Gateway under a Koa host sub-path', () => { + it( + 'should keep the host prefix in the docs links', + async () => { + const agent = buildAgent('koa-sub-path').addGateway({ basePath: '/ai', api: API_OPTIONS }); + const hostApp = new Koa(); + hostApp.use(async (ctx, next) => { + if (ctx.path.startsWith('/host/')) ctx.url = ctx.url.slice('/host'.length); + await next(); + }); + agent.mountOnKoa(hostApp); + await agent.start(); + + try { + const page = await request(hostApp.callback()).get('/host/ai/api/docs'); + + expect(page.text).toContain('/host/ai/api/docs/redoc.standalone.js'); + } finally { + await agent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); +}); + +describe.each(['', '/ai'])('the embedded Gateway smoke on basePath "%s"', basePath => { + async function statusesOn(baseUrl: string) { + const mcp = await request(baseUrl).post(`${basePath}/mcp`); + const health = await request(baseUrl).get(`${basePath}/api/health`); + + return [mcp.status, health.status]; + } + + it( + 'should serve both services on mountOnFastify', + async () => { + const agent = buildAgent(`fastify${basePath.replace('/', '-')}`).addGateway({ + basePath, + mcp: true, + api: API_OPTIONS, + }); + const app = Fastify(); + agent.mountOnFastify(app); + await app.listen(0, '127.0.0.1'); + await agent.start(); + + try { + const { port } = app.server.address() as { port: number }; + expect(await statusesOn(`http://127.0.0.1:${port}`)).toEqual([401, 200]); + } finally { + await agent.stop(); + await app.close(); + } + }, + BOOT_TIMEOUT_MS, + ); + + it( + 'should serve both services on mountOnStandaloneServer', + async () => { + const agent = buildAgent(`standalone${basePath.replace('/', '-')}`).addGateway({ + basePath, + mcp: true, + api: API_OPTIONS, + }); + agent.mountOnStandaloneServer(0, '127.0.0.1'); + await agent.start(); + + try { + expect(await statusesOn(`http://127.0.0.1:${agent.standaloneServerPort}`)).toEqual([ + 401, 200, + ]); + } finally { + await agent.stop(); + } + }, + BOOT_TIMEOUT_MS, + ); +}); diff --git a/packages/agent/test/utils/express-to-koa.test.ts b/packages/agent/test/utils/express-to-koa.test.ts new file mode 100644 index 0000000000..e8932d08f2 --- /dev/null +++ b/packages/agent/test/utils/express-to-koa.test.ts @@ -0,0 +1,54 @@ +import type { IncomingMessage, ServerResponse } from 'http'; + +import Koa from 'koa'; +import request from 'supertest'; + +import expressToKoa from '../../src/utils/express-to-koa'; + +type Req = IncomingMessage & { originalUrl?: string }; + +function koaHostStripping(prefix: string) { + const seen: Array<{ url?: string; originalUrl?: string }> = []; + const app = new Koa(); + app.use(async (ctx, next) => { + if (ctx.path.startsWith(`${prefix}/`)) ctx.url = ctx.url.slice(prefix.length); + await next(); + }); + app.use( + expressToKoa((req: Req, res: ServerResponse) => { + seen.push({ url: req.url, originalUrl: req.originalUrl }); + res.end(); + }), + ); + + return { app, seen }; +} + +describe('expressToKoa', () => { + it('should hand the callback the url Koa received before the host rewrote it', async () => { + const { app, seen } = koaHostStripping('/host'); + + await request(app.callback()).get('/host/api/health?probe=1'); + + expect(seen).toEqual([{ url: '/api/health?probe=1', originalUrl: '/host/api/health?probe=1' }]); + }); + + it('should keep an originalUrl already set on the request', async () => { + const app = new Koa(); + const seen: Array = []; + app.use(async (ctx, next) => { + (ctx.req as Req).originalUrl = '/set-by-host'; + await next(); + }); + app.use( + expressToKoa((req: Req, res: ServerResponse) => { + seen.push(req.originalUrl); + res.end(); + }), + ); + + await request(app.callback()).get('/anything'); + + expect(seen).toEqual(['/set-by-host']); + }); +}); From 96baf206038170430e03d24a744af62525e2b9c1 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Tue, 6 Oct 2026 15:31:55 +0200 Subject: [PATCH 03/11] feat(agent-bff): apply the gateway allowlist to API OAuth (#1974) --- packages/agent-bff/src/build-bff.ts | 18 ++- packages/agent-bff/src/oauth/oauth-routes.ts | 24 ++++ packages/agent-bff/test/build-bff.test.ts | 78 +++++++++- .../agent-bff/test/oauth/oauth-routes.test.ts | 133 ++++++++++++++++++ packages/agent/src/agent.ts | 22 ++- packages/agent/src/embedded-bff.ts | 2 + packages/agent/src/gateway.ts | 4 + packages/agent/test/agent-gateway.test.ts | 82 +++++++++++ .../test/gateway/embedded-gateway.e2e.test.ts | 111 +++++++++++++++ packages/forestadmin-client/src/index.ts | 3 + .../is-client-allowed.ts | 29 ++++ .../normalize-domain-list.ts | 0 .../parse-domain-list.ts | 0 .../is-client-allowed.test.ts | 65 +++++++++ .../normalize-domain-list.test.ts | 2 +- .../parse-domain-list.test.ts | 2 +- packages/mcp-server/src/cli.ts | 3 +- .../mcp-server/src/forest-oauth-provider.ts | 33 +---- packages/mcp-server/src/server.ts | 2 +- .../test/forest-oauth-provider.test.ts | 5 +- 20 files changed, 575 insertions(+), 43 deletions(-) create mode 100644 packages/forestadmin-client/src/oauth-client-allowlist/is-client-allowed.ts rename packages/{mcp-server/src/utils => forestadmin-client/src/oauth-client-allowlist}/normalize-domain-list.ts (100%) rename packages/{mcp-server/src/utils => forestadmin-client/src/oauth-client-allowlist}/parse-domain-list.ts (100%) create mode 100644 packages/forestadmin-client/test/oauth-client-allowlist/is-client-allowed.test.ts rename packages/{mcp-server/test/utils => forestadmin-client/test/oauth-client-allowlist}/normalize-domain-list.test.ts (95%) rename packages/{mcp-server/test/utils => forestadmin-client/test/oauth-client-allowlist}/parse-domain-list.test.ts (93%) diff --git a/packages/agent-bff/src/build-bff.ts b/packages/agent-bff/src/build-bff.ts index 05c87ebca3..459e6d60cf 100644 --- a/packages/agent-bff/src/build-bff.ts +++ b/packages/agent-bff/src/build-bff.ts @@ -11,6 +11,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'; @@ -82,6 +83,12 @@ 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[]; } export interface Bff { @@ -204,7 +211,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) { @@ -232,6 +243,7 @@ function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): OAuthEdge { authSecret: forestAuthSecret, resolveEnvironmentId, logger, + allowedOAuthClients, }); return { @@ -529,10 +541,12 @@ export default async function buildBff({ basePath, dispatcher, metrics, + allowedOAuthClients, }: BuildBffOptions): Promise { // 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', { @@ -550,7 +564,7 @@ export default async function buildBff({ } 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, diff --git a/packages/agent-bff/src/oauth/oauth-routes.ts b/packages/agent-bff/src/oauth/oauth-routes.ts index 96c893be90..728567c702 100644 --- a/packages/agent-bff/src/oauth/oauth-routes.ts +++ b/packages/agent-bff/src/oauth/oauth-routes.ts @@ -6,6 +6,7 @@ import type { Logger } from '../ports/logger-port'; import type { UserInfo } from '@forestadmin/forestadmin-client'; import type { Context, Middleware } from 'koa'; +import { isClientAllowed } from '@forestadmin/forestadmin-client'; import crypto from 'crypto'; import jsonwebtoken from 'jsonwebtoken'; @@ -36,6 +37,7 @@ export interface OAuthRoutesOptions { authSecret: string; resolveEnvironmentId: EnvironmentIdResolver; logger: Logger; + allowedOAuthClients?: string[]; } const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; @@ -90,6 +92,26 @@ function assertRegisteredRedirectUri( } } +const CLIENT_NOT_APPROVED_MESSAGE = + 'This API only accepts approved client applications. Contact your Forest Admin administrator.'; + +function assertClientIsAllowed( + clientId: string, + redirectUris: string[] | undefined, + options: OAuthRoutesOptions, +): void { + const { allowedOAuthClients } = options; + + if (!allowedOAuthClients || isClientAllowed(redirectUris, allowedOAuthClients)) return; + + options.logger('Info', 'Rejected OAuth client: redirect URIs are not all on an allowed domain', { + clientId, + redirectUris: redirectUris ?? [], + }); + + throw invalidClient(CLIENT_NOT_APPROVED_MESSAGE); +} + function toSafeExchangeError(saasError: string): OAuthRequestError { if (SAFE_EXCHANGE_ERRORS.has(saasError)) { return new OAuthRequestError(400, saasError, 'Authorization code exchange failed'); @@ -165,6 +187,7 @@ async function handleAuthorize(ctx: Context, options: OAuthRoutesOptions): Promi throw invalidRequest('Unknown client_id'); } + assertClientIsAllowed(clientId, client.redirect_uris, options); assertRegisteredRedirectUri(client.redirect_uris, redirectUri, invalidRequest); // redirect_uri is now trusted: remaining validation errors redirect back to @@ -260,6 +283,7 @@ async function parseTokenRequest(ctx: Context, options: OAuthRoutesOptions): Pro throw invalidClient('Unknown client_id'); } + assertClientIsAllowed(request.clientId, client.redirect_uris, options); assertRegisteredRedirectUri(client.redirect_uris, request.redirectUri, invalidGrant); return request; diff --git a/packages/agent-bff/test/build-bff.test.ts b/packages/agent-bff/test/build-bff.test.ts index 2721aee715..751c36b4e2 100644 --- a/packages/agent-bff/test/build-bff.test.ts +++ b/packages/agent-bff/test/build-bff.test.ts @@ -4,7 +4,7 @@ import request from 'supertest'; import { createHttpTransport } from '../src/agent/agent-transport'; import buildBff from '../src/build-bff'; -import { restoreFetchAfterEach, stubEnvironmentIdFetch } from './helpers/fetch-stub'; +import { restoreFetchAfterEach, stubEnvironmentIdFetch, stubFetch } from './helpers/fetch-stub'; import { parseConfig } from '../src/config/env-config'; import version from '../src/version'; @@ -194,6 +194,82 @@ describe('buildBff', () => { }); }); + describe('when allowedOAuthClients is set', () => { + const ZENDESK_REDIRECT_URI = 'https://app.forestadmin.com/zendesk-oauth-redirect'; + const AUTHORIZE_QUERY = { + client_id: 'zendesk', + redirect_uri: ZENDESK_REDIRECT_URI, + response_type: 'code', + code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + code_challenge_method: 'S256', + state: 'state-xyz', + }; + + function stubRegisteredClientFetch(): void { + const ok = (body: unknown) => ({ + ok: true, + status: 200, + statusText: 'ok', + json: async () => body, + }); + + stubFetch(undefined).mockImplementation(async (url: string) => + String(url).includes('/oauth/register/') + ? ok({ client_id: 'zendesk', redirect_uris: [ZENDESK_REDIRECT_URI] }) + : ok({ data: { id: '42' } }), + ); + } + + it('should reject an OAuth client outside the normalized allowed domains', async () => { + stubRegisteredClientFetch(); + const { callback } = await buildBff({ + config: parseConfig(VALID_ENV), + logger: noopLogger, + allowedOAuthClients: [' CLAUDE.ai '], + }); + + const response = await request(callback).get('/oauth/authorize').query(AUTHORIZE_QUERY); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(response.headers.location).toBeUndefined(); + }); + + it('should accept an OAuth client on an allowed domain', async () => { + stubRegisteredClientFetch(); + const { callback } = await buildBff({ + config: parseConfig(VALID_ENV), + logger: noopLogger, + allowedOAuthClients: ['forestadmin.com'], + }); + + const response = await request(callback).get('/oauth/authorize').query(AUTHORIZE_QUERY); + + expect(response.status).toBe(302); + const location = new URL(response.headers.location); + expect(location.origin + location.pathname).toBe( + `${VALID_ENV.FOREST_APP_URL}/oauth/authorize`, + ); + expect(location.searchParams.get('environmentId')).toBe('42'); + }); + + it('should refuse to build on a list with no domain', async () => { + await expect( + buildBff({ config: parseConfig(VALID_ENV), logger: noopLogger, allowedOAuthClients: [] }), + ).rejects.toThrow('Invalid allowedOAuthClients: no domains to allow'); + }); + + it('should refuse to build on an entry that is not a bare domain', async () => { + await expect( + buildBff({ + config: parseConfig(VALID_ENV), + logger: noopLogger, + allowedOAuthClients: ['https://claude.ai'], + }), + ).rejects.toThrow('Invalid allowedOAuthClients entry "https://claude.ai"'); + }); + }); + describe('when BFF_ALLOWED_ORIGINS carries a malformed entry', () => { it('should warn once with the rejected entries', async () => { const logger = jest.fn(); diff --git a/packages/agent-bff/test/oauth/oauth-routes.test.ts b/packages/agent-bff/test/oauth/oauth-routes.test.ts index af997b01e6..11b5eaa242 100644 --- a/packages/agent-bff/test/oauth/oauth-routes.test.ts +++ b/packages/agent-bff/test/oauth/oauth-routes.test.ts @@ -86,6 +86,7 @@ function buildApp( forestAppUrl: string = APP_URL, onTokenRequest?: () => void, resolveEnvironmentId: () => Promise = async () => 99, + allowedOAuthClients?: string[], ) { const logs: LogLine[] = []; @@ -118,6 +119,7 @@ function buildApp( authSecret: AUTH_SECRET, resolveEnvironmentId, logger, + allowedOAuthClients, }), ); @@ -1106,3 +1108,134 @@ describe('oauth-routes middleware', () => { }); }); }); + +describe('oauth-routes allowedOAuthClients', () => { + const ALLOWED_REDIRECT_URI = 'https://app.dust.tt/oauth/callback'; + const ZENDESK_REDIRECT_URI = 'https://app.forestadmin.com/zendesk-oauth-redirect'; + + function buildRestrictedApp(redirectUris: string[], allowedOAuthClients?: string[]) { + const exchangeCode = jest.fn(async () => serverTokens()); + const getRegisteredClient = jest.fn(async () => ({ + client_id: CLIENT_ID, + redirect_uris: redirectUris, + })); + const built = buildApp( + stubServerClient({ exchangeCode, getRegisteredClient }), + APP_URL, + undefined, + async () => 99, + allowedOAuthClients, + ); + + return { ...built, exchangeCode }; + } + + describe('on GET /oauth/authorize', () => { + it('should reject a client outside the allowed domains with a 400 invalid_client body and no redirect', async () => { + const { app } = buildRestrictedApp([ZENDESK_REDIRECT_URI], ['claude.ai']); + + const response = await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, redirect_uri: ZENDESK_REDIRECT_URI }); + + expect(response.status).toBe(400); + expect(response.headers['content-type']).toMatch(/application\/json/); + expect(response.body).toEqual({ + error: 'invalid_client', + error_description: + 'This API only accepts approved client applications. Contact your Forest Admin administrator.', + }); + expect(response.headers.location).toBeUndefined(); + expect(JSON.stringify(response.body)).not.toContain('claude.ai'); + }); + + it('should reject a client outside the allowed domains even when the redirect_uri is not registered', async () => { + const { app } = buildRestrictedApp([ZENDESK_REDIRECT_URI], ['claude.ai']); + + const response = await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, redirect_uri: 'https://elsewhere.example/cb' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + }); + + it('should log the rejected client id and its redirect URIs', async () => { + const { app, logs } = buildRestrictedApp([ZENDESK_REDIRECT_URI], ['claude.ai']); + + await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, redirect_uri: ZENDESK_REDIRECT_URI }); + + expect(logs).toContainEqual({ + level: 'Info', + message: 'Rejected OAuth client: redirect URIs are not all on an allowed domain', + context: { clientId: CLIENT_ID, redirectUris: [ZENDESK_REDIRECT_URI] }, + }); + }); + + it('should redirect to the Forest authorize url for a client on an allowed domain', async () => { + const { app } = buildRestrictedApp([ALLOWED_REDIRECT_URI], ['dust.tt']); + + const response = await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, redirect_uri: ALLOWED_REDIRECT_URI }); + + expect(response.status).toBe(302); + const location = new URL(response.headers.location); + expect(location.origin + location.pathname).toBe(`${APP_URL}/oauth/authorize`); + expect(location.searchParams.get('redirect_uri')).toBe(ALLOWED_REDIRECT_URI); + }); + + it('should accept any registered client when no allowlist is set', async () => { + const { app } = buildRestrictedApp([ZENDESK_REDIRECT_URI]); + + const response = await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, redirect_uri: ZENDESK_REDIRECT_URI }); + + expect(response.status).toBe(302); + expect(new URL(response.headers.location).pathname).toBe('/oauth/authorize'); + }); + }); + + describe('on POST /oauth/token with grant_type=authorization_code', () => { + it('should reject a client outside the allowed domains with invalid_client and call no SaaS exchange', async () => { + const { app, exchangeCode } = buildRestrictedApp([ZENDESK_REDIRECT_URI], ['claude.ai']); + + const response = await request(app.callback()) + .post('/oauth/token') + .send({ ...TOKEN_BODY, redirect_uri: ZENDESK_REDIRECT_URI }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(JSON.stringify(response.body)).not.toContain('claude.ai'); + expect(exchangeCode).not.toHaveBeenCalled(); + }); + + it('should issue tokens to a client on an allowed domain', async () => { + const { app, exchangeCode } = buildRestrictedApp([ALLOWED_REDIRECT_URI], ['dust.tt']); + + const response = await request(app.callback()) + .post('/oauth/token') + .send({ ...TOKEN_BODY, redirect_uri: ALLOWED_REDIRECT_URI }); + + expect(response.status).toBe(200); + expect(response.body.token_type).toBe('Bearer'); + expect(exchangeCode).toHaveBeenCalledWith( + expect.objectContaining({ clientId: CLIENT_ID, redirectUri: ALLOWED_REDIRECT_URI }), + ); + }); + + it('should issue tokens to any registered client when no allowlist is set', async () => { + const { app } = buildRestrictedApp([ZENDESK_REDIRECT_URI]); + + const response = await request(app.callback()) + .post('/oauth/token') + .send({ ...TOKEN_BODY, redirect_uri: ZENDESK_REDIRECT_URI }); + + expect(response.status).toBe(200); + expect(response.body.token_type).toBe('Bearer'); + }); + }); +}); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 81faa0f715..1c6aff82ef 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -40,6 +40,7 @@ import { describeGatewayRoutes, mcpUnavailable, resolveGatewayServices, + servesOAuth, } from './gateway'; import makeRoutes from './routes'; import makeServices from './services'; @@ -97,6 +98,7 @@ export default class Agent extends FrameworkMounter private gateway: { basePath: string; services: GatewayServices; + allowedOAuthClients?: string[]; build?: (mcp?: RootHandler) => RootHandler; } | null = null; @@ -371,7 +373,11 @@ export default class Agent extends FrameworkMounter if (this.mcpEnabled || this.embeddedBff) throw new Error(GATEWAY_MIXING); const services = resolveGatewayServices(options); - this.gateway = { basePath: options.basePath ?? '', services }; + this.gateway = { + basePath: options.basePath ?? '', + services, + allowedOAuthClients: options.allowedOAuthClients, + }; if (services.mcp) { this.mcpEnabled = true; @@ -570,6 +576,7 @@ export default class Agent extends FrameworkMounter name: 'The Gateway API', requiredVersion: AGENT_BFF_PEER_VERSION, skipIpWhitelistWarning: Boolean(this.gateway?.services.mcp), + allowedOAuthClients: this.gateway?.allowedOAuthClients, }); return this.embeddedBff; @@ -603,9 +610,16 @@ export default class Agent extends FrameworkMounter private logGatewayRoutes(): void { if (!this.gateway) return; - describeGatewayRoutes(this.gateway.basePath, this.gateway.services).forEach(line => - this.options.logger('Info', line), - ); + const { basePath, services, allowedOAuthClients } = this.gateway; + + describeGatewayRoutes(basePath, services).forEach(line => this.options.logger('Info', line)); + + if (allowedOAuthClients && !servesOAuth(services)) { + this.options.logger( + 'Warn', + '[Gateway] allowedOAuthClients is set but no OAuth route is served (MCP off, API OAuth off).', + ); + } } protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) { diff --git a/packages/agent/src/embedded-bff.ts b/packages/agent/src/embedded-bff.ts index 3d4d006b1c..bfa995288d 100644 --- a/packages/agent/src/embedded-bff.ts +++ b/packages/agent/src/embedded-bff.ts @@ -8,6 +8,7 @@ export type EmbeddedBffMount = { name?: string; requiredVersion?: string; skipIpWhitelistWarning?: boolean; + allowedOAuthClients?: string[]; }; /** How long `stop()` waits for the activity-log writes no connection holds. */ @@ -122,6 +123,7 @@ export default class EmbeddedBff { config: this.config as BFFConfig, dispatcher, basePath: this.mount.prefix, + allowedOAuthClients: this.mount.allowedOAuthClients, // Counters are the schema cache's and the action-endpoint resolver's only channel — they take // no logger — and every one of them reports a failure, so they go to the host's logs. Gauges // do not: they are periodic cache sizes, and the default console sink reports them at Info, diff --git a/packages/agent/src/gateway.ts b/packages/agent/src/gateway.ts index 4e33d86f4f..e0bb7679c3 100644 --- a/packages/agent/src/gateway.ts +++ b/packages/agent/src/gateway.ts @@ -102,6 +102,10 @@ function servesInMemoryUploads({ fileUploads, enabledTools }: GatewayMcpOptions) return !enabledTools || enabledTools.includes('requestActionFileUpload'); } +export function servesOAuth({ mcp, api }: GatewayServices): boolean { + return Boolean(mcp || api?.tokenEncryptionKey); +} + export function describeGatewayRoutes(basePath: string, { mcp, api }: GatewayServices): string[] { const lines: string[] = []; diff --git a/packages/agent/test/agent-gateway.test.ts b/packages/agent/test/agent-gateway.test.ts index 4214edb305..6145b8af58 100644 --- a/packages/agent/test/agent-gateway.test.ts +++ b/packages/agent/test/agent-gateway.test.ts @@ -179,6 +179,37 @@ describe('Agent.addGateway', () => { ); }); + it('should warn that the allowlist protects nothing when no OAuth route is served', async () => { + const { agent, logger } = buildAgent(); + agent.addGateway({ allowedOAuthClients: ['claude.ai'], api: {} }); + + await agent.start(); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + '[Gateway] allowedOAuthClients is set but no OAuth route is served (MCP off, API OAuth off).', + ); + }); + + it.each<[string, GatewayOptions]>([ + ['the MCP is on', { allowedOAuthClients: ['claude.ai'], mcp: true, api: {} }], + [ + 'API OAuth is on', + { allowedOAuthClients: ['claude.ai'], api: { tokenEncryptionKey: 'key' } }, + ], + ['no allowlist is set', { api: {} }], + ])('should not warn about the allowlist when %s', async (_case, options) => { + const { agent, logger } = buildAgent(); + agent.addGateway(options); + + await agent.start(); + + expect(logger).not.toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('allowedOAuthClients is set'), + ); + }); + it('should log only the enabled service', async () => { const { agent, logger } = buildAgent(); agent.addGateway({ mcp: true, api: false }); @@ -228,6 +259,46 @@ describe('Agent.addGateway', () => { ); }); + it('should hand the API the Gateway allowlist when only the API is on', async () => { + const { agent } = buildAgent(); + agent.addGateway({ allowedOAuthClients: ['claude.ai'], api: { tokenEncryptionKey: 'key' } }); + + await agent.start(); + + expect(mockBuildBff).toHaveBeenCalledWith( + expect.objectContaining({ basePath: '/api', allowedOAuthClients: ['claude.ai'] }), + ); + }); + + it('should hand the same allowlist to both services', async () => { + const { agent } = buildAgent(); + agent.addGateway({ + allowedOAuthClients: ['claude.ai'], + mcp: true, + api: { tokenEncryptionKey: 'key' }, + }); + + await agent.start(); + + expect(mcpServerSpy).toHaveBeenCalledWith( + expect.objectContaining({ allowedOAuthClients: ['claude.ai'] }), + ); + expect(mockBuildBff).toHaveBeenCalledWith( + expect.objectContaining({ allowedOAuthClients: ['claude.ai'] }), + ); + }); + + it('should hand the API no allowlist when the Gateway has none', async () => { + const { agent } = buildAgent(); + agent.addGateway({ api: { tokenEncryptionKey: 'key' } }); + + await agent.start(); + + expect(mockBuildBff).toHaveBeenCalledWith( + expect.objectContaining({ allowedOAuthClients: undefined }), + ); + }); + it('should not load the MCP server when only the API is on', async () => { const { agent } = buildAgent(); agent.addGateway({ api: {} }); @@ -414,4 +485,15 @@ describe('Agent.addBff (deprecated alias)', () => { expect((await request('/api/agent/v1/books/list')).status).toBe(404); expect((await request('/api/health')).status).toBe(404); }); + + it('should hand the BFF no allowlist', async () => { + const { agent } = buildAgent(); + agent.addBff({ tokenEncryptionKey: 'key' }); + + await agent.start(); + + expect(mockBuildBff).toHaveBeenCalledWith( + expect.objectContaining({ basePath: '/bff', allowedOAuthClients: undefined }), + ); + }); }); diff --git a/packages/agent/test/gateway/embedded-gateway.e2e.test.ts b/packages/agent/test/gateway/embedded-gateway.e2e.test.ts index 1e1626c238..f5b98f05c6 100644 --- a/packages/agent/test/gateway/embedded-gateway.e2e.test.ts +++ b/packages/agent/test/gateway/embedded-gateway.e2e.test.ts @@ -24,6 +24,8 @@ const CODE_VERIFIER = 'v'.repeat(43); const CODE_CHALLENGE = 'c'.repeat(43); const TIMEZONE = 'Europe/Paris'; const LIST_BODY = { projection: ['id', 'title'] }; +const ZENDESK_CLIENT_ID = 'zendesk-client'; +const ZENDESK_REDIRECT_URI = 'https://app.forestadmin.com/zendesk-oauth-redirect'; let codeCount = 0; @@ -83,6 +85,10 @@ function startMockForestServer(): MockForestServer { }, }, }) + .get(`/oauth/register/${ZENDESK_CLIENT_ID}`, { + client_id: ZENDESK_CLIENT_ID, + redirect_uris: [ZENDESK_REDIRECT_URI], + }) .setupDefaultRoutes({ envSecret: ENV_SECRET, collections: COLLECTIONS }) .setupSuperagentMock() .setupFetchMock(); @@ -288,6 +294,111 @@ describe.each(['', '/ai'])('the embedded Gateway on basePath "%s"', basePath => }); }); +describe.each(['', '/ai'])('the embedded Gateway allowlist on basePath "%s"', basePath => { + const authorizeQuery = (clientId: string, redirectUri: string) => ({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + code_challenge: CODE_CHALLENGE, + code_challenge_method: 'S256', + state: 'state-1', + }); + + async function withGateway( + name: string, + allowedOAuthClients: string[] | undefined, + run: (app: express.Express) => Promise, + ) { + const agent = buildAgent(`${name}${basePath.replace('/', '-')}`).addGateway({ + basePath, + allowedOAuthClients, + api: API_OPTIONS, + }); + const app = await startOn(agent); + + try { + await run(app); + } finally { + await agent.stop(); + } + } + + it( + 'should answer authorize with a 400 invalid_client body for a client outside the list', + async () => { + await withGateway('allowlist-authorize', ['claude.ai'], async app => { + const response = await request(app) + .get(`${basePath}/oauth/authorize?service=api`) + .query(authorizeQuery(ZENDESK_CLIENT_ID, ZENDESK_REDIRECT_URI)); + + expect(response.status).toBe(400); + expect(response.headers['content-type']).toMatch(/application\/json/); + expect(response.body.error).toBe('invalid_client'); + expect(response.headers.location).toBeUndefined(); + expect(response.text).not.toContain('claude.ai'); + }); + }, + BOOT_TIMEOUT_MS, + ); + + it( + 'should refuse the code exchange of a client outside the list', + async () => { + await withGateway('allowlist-token', ['claude.ai'], async app => { + const response = await request(app) + .post(`${basePath}/oauth/token?service=api`) + .set('Content-Type', 'application/json') + .send({ + grant_type: 'authorization_code', + client_id: ZENDESK_CLIENT_ID, + code: nextCode(), + code_verifier: CODE_VERIFIER, + redirect_uri: ZENDESK_REDIRECT_URI, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(response.body.access_token).toBeUndefined(); + }); + }, + BOOT_TIMEOUT_MS, + ); + + it( + 'should sign in a client whose redirect URIs are all on a listed domain', + async () => { + await withGateway('allowlist-allowed', ['localhost'], async app => { + const authorize = await oauth(`${basePath}/oauth`, '?service=api').authorize(app); + const token = await signIn(app, `${basePath}/oauth`, '?service=api'); + + expect(authorize.status).toBe(302); + expect(authorize.headers.location).toMatch( + /^https:\/\/app\.forestadmin\.com\/oauth\/authorize\?/, + ); + expect(jsonwebtoken.verify(token, AUTH_SECRET)).toMatchObject({ type: 'bff_access' }); + }); + }, + BOOT_TIMEOUT_MS, + ); + + it( + 'should sign in any registered client without a list', + async () => { + await withGateway('allowlist-none', undefined, async app => { + const authorize = await request(app) + .get(`${basePath}/oauth/authorize?service=api`) + .query(authorizeQuery(ZENDESK_CLIENT_ID, ZENDESK_REDIRECT_URI)); + + expect(authorize.status).toBe(302); + expect(authorize.headers.location).toMatch( + /^https:\/\/app\.forestadmin\.com\/oauth\/authorize\?/, + ); + }); + }, + BOOT_TIMEOUT_MS, + ); +}); + describe('the embedded Gateway on a basePath', () => { it( 'should leave the root /api paths to the host', diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 1abe966025..9df2c9d979 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -128,6 +128,9 @@ export { default as ServerUtils } from './utils/server'; export { default as SchemaService, SchemaServiceOptions } from './schema'; export { default as ActivityLogsService, ActivityLogsOptions } from './activity-logs'; export { default as WorkflowsService, WorkflowsServiceOptions } from './workflows'; +export { default as parseDomainList } from './oauth-client-allowlist/parse-domain-list'; +export { default as normalizeDomainList } from './oauth-client-allowlist/normalize-domain-list'; +export { default as isClientAllowed } from './oauth-client-allowlist/is-client-allowed'; export * from './auth/errors'; export * from './utils/errors'; diff --git a/packages/forestadmin-client/src/oauth-client-allowlist/is-client-allowed.ts b/packages/forestadmin-client/src/oauth-client-allowlist/is-client-allowed.ts new file mode 100644 index 0000000000..4f2f447a69 --- /dev/null +++ b/packages/forestadmin-client/src/oauth-client-allowlist/is-client-allowed.ts @@ -0,0 +1,29 @@ +const WEB_PROTOCOLS = ['https:', 'http:']; + +function isUriOnAllowedDomain(redirectUri: string, allowedDomains: string[]): boolean { + let hostname: string; + let protocol: string; + + try { + ({ hostname, protocol } = new URL(redirectUri)); + } catch { + return false; + } + + if (!WEB_PROTOCOLS.includes(protocol)) return false; + + return allowedDomains.some(domain => { + const allowedDomain = domain.toLowerCase(); + + return hostname === allowedDomain || hostname.endsWith(`.${allowedDomain}`); + }); +} + +export default function isClientAllowed( + redirectUris: string[] | undefined, + allowedDomains: string[], +): boolean { + const uris = redirectUris ?? []; + + return uris.length > 0 && uris.every(uri => isUriOnAllowedDomain(uri, allowedDomains)); +} diff --git a/packages/mcp-server/src/utils/normalize-domain-list.ts b/packages/forestadmin-client/src/oauth-client-allowlist/normalize-domain-list.ts similarity index 100% rename from packages/mcp-server/src/utils/normalize-domain-list.ts rename to packages/forestadmin-client/src/oauth-client-allowlist/normalize-domain-list.ts diff --git a/packages/mcp-server/src/utils/parse-domain-list.ts b/packages/forestadmin-client/src/oauth-client-allowlist/parse-domain-list.ts similarity index 100% rename from packages/mcp-server/src/utils/parse-domain-list.ts rename to packages/forestadmin-client/src/oauth-client-allowlist/parse-domain-list.ts diff --git a/packages/forestadmin-client/test/oauth-client-allowlist/is-client-allowed.test.ts b/packages/forestadmin-client/test/oauth-client-allowlist/is-client-allowed.test.ts new file mode 100644 index 0000000000..bb00d97788 --- /dev/null +++ b/packages/forestadmin-client/test/oauth-client-allowlist/is-client-allowed.test.ts @@ -0,0 +1,65 @@ +import isClientAllowed from '../../src/oauth-client-allowlist/is-client-allowed'; + +describe('isClientAllowed', () => { + it('should allow a client whose every redirect URI is on an allowed domain', () => { + expect(isClientAllowed(['https://dust.tt/oauth/finalize'], ['dust.tt'])).toBe(true); + }); + + it('should allow redirect URIs on subdomains of an allowed domain', () => { + expect( + isClientAllowed(['https://eu.dust.tt/cb', 'https://mcp.front.eu.dust.tt/cb'], ['dust.tt']), + ).toBe(true); + }); + + it('should allow a client matched by any of several domains', () => { + expect(isClientAllowed(['https://claude.ai/cb'], ['dust.tt', 'claude.ai'])).toBe(true); + }); + + it('should match case-insensitively', () => { + expect(isClientAllowed(['https://EU.Dust.TT/cb'], ['DUST.tt'])).toBe(true); + }); + + it('should allow plain http redirect URIs on an allowed domain', () => { + expect(isClientAllowed(['http://dust.tt/cb'], ['dust.tt'])).toBe(true); + }); + + it('should reject a client having one redirect URI outside the allowed domains', () => { + expect(isClientAllowed(['https://dust.tt/cb', 'https://evil.example/cb'], ['dust.tt'])).toBe( + false, + ); + }); + + it('should reject a lookalike domain that merely ends with an allowed domain', () => { + expect(isClientAllowed(['https://evil-dust.tt/cb'], ['dust.tt'])).toBe(false); + }); + + it('should reject a sibling domain that only shares a split top-level domain', () => { + expect(isClientAllowed(['https://othervendor.co.uk/cb'], ['myvendor.co.uk'])).toBe(false); + }); + + it('should reject a custom-scheme redirect URI whose hostname is on an allowed domain', () => { + expect(isClientAllowed(['attacker-app://dust.tt/cb'], ['dust.tt'])).toBe(false); + }); + + it('should reject userinfo pointing at an allowed domain', () => { + expect(isClientAllowed(['https://dust.tt@evil.example/cb'], ['dust.tt'])).toBe(false); + }); + + it('should reject an empty redirect URI list', () => { + expect(isClientAllowed([], ['dust.tt'])).toBe(false); + }); + + it('should reject missing redirect URIs', () => { + expect(isClientAllowed(undefined, ['dust.tt'])).toBe(false); + }); + + it('should reject an unparseable redirect URI', () => { + expect(isClientAllowed(['not-a-valid-url'], ['dust.tt'])).toBe(false); + }); + + it('should reject the zendesk app redirect URI when only claude.ai is allowed', () => { + expect( + isClientAllowed(['https://app.forestadmin.com/zendesk-oauth-redirect'], ['claude.ai']), + ).toBe(false); + }); +}); diff --git a/packages/mcp-server/test/utils/normalize-domain-list.test.ts b/packages/forestadmin-client/test/oauth-client-allowlist/normalize-domain-list.test.ts similarity index 95% rename from packages/mcp-server/test/utils/normalize-domain-list.test.ts rename to packages/forestadmin-client/test/oauth-client-allowlist/normalize-domain-list.test.ts index e06a271e94..6be37f80a5 100644 --- a/packages/mcp-server/test/utils/normalize-domain-list.test.ts +++ b/packages/forestadmin-client/test/oauth-client-allowlist/normalize-domain-list.test.ts @@ -1,4 +1,4 @@ -import normalizeDomainList from '../../src/utils/normalize-domain-list'; +import normalizeDomainList from '../../src/oauth-client-allowlist/normalize-domain-list'; describe('normalizeDomainList', () => { it('should return undefined when the option is not configured', () => { diff --git a/packages/mcp-server/test/utils/parse-domain-list.test.ts b/packages/forestadmin-client/test/oauth-client-allowlist/parse-domain-list.test.ts similarity index 93% rename from packages/mcp-server/test/utils/parse-domain-list.test.ts rename to packages/forestadmin-client/test/oauth-client-allowlist/parse-domain-list.test.ts index 83bbf3cc74..b4d84bd5a4 100644 --- a/packages/mcp-server/test/utils/parse-domain-list.test.ts +++ b/packages/forestadmin-client/test/oauth-client-allowlist/parse-domain-list.test.ts @@ -1,4 +1,4 @@ -import parseDomainList from '../../src/utils/parse-domain-list'; +import parseDomainList from '../../src/oauth-client-allowlist/parse-domain-list'; describe('parseDomainList', () => { it('should return undefined when env value is undefined', () => { diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 67eb6fa05c..00af3e617e 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node +import { parseDomainList } from '@forestadmin/forestadmin-client'; + import ForestMCPServer from './server'; import loadFileUploads from './utils/load-file-uploads'; -import parseDomainList from './utils/parse-domain-list'; import parseToolList from './utils/parse-tool-list'; const toSeconds = (value?: string) => (value === undefined ? undefined : Number(value)); diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index 03af696aa5..f4a2b4ddda 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -15,7 +15,7 @@ import type { import type { Response } from 'express'; import { toAgentTokenClaims } from '@forestadmin/agent-client'; -import createForestAdminClient from '@forestadmin/forestadmin-client'; +import createForestAdminClient, { isClientAllowed } from '@forestadmin/forestadmin-client'; import { CustomOAuthError, InvalidClientError, @@ -191,16 +191,9 @@ export default class ForestOAuthProvider implements OAuthServerProvider { private assertClientIsAllowed(client: OAuthClientInformationFull): void { const allowedDomains = this.allowedOAuthClients; - if (!allowedDomains) return; + if (!allowedDomains || isClientAllowed(client.redirect_uris, allowedDomains)) return; - // every() over an empty list is vacuously true, so a registration without - // redirect URIs must be rejected explicitly rather than slip through. const redirectUris = client.redirect_uris ?? []; - const isAllowed = - redirectUris.length > 0 && - redirectUris.every(uri => ForestOAuthProvider.isUriOnAllowedDomain(uri, allowedDomains)); - - if (isAllowed) return; this.logger( 'Info', @@ -215,28 +208,6 @@ export default class ForestOAuthProvider implements OAuthServerProvider { ); } - private static isUriOnAllowedDomain(redirectUri: string, allowedDomains: string[]): boolean { - let hostname: string; - let protocol: string; - - try { - // URL normalizes the host to lowercase punycode, closing case and homograph tricks. - ({ hostname, protocol } = new URL(redirectUri)); - } catch { - return false; - } - - // A custom scheme (attacker-app://dust.tt/cb) dispatches the callback to whatever - // application registered it, so its hostname says nothing about where the code lands. - if (protocol !== 'https:' && protocol !== 'http:') return false; - - return allowedDomains.some(domain => { - const allowedDomain = domain.toLowerCase(); - - return hostname === allowedDomain || hostname.endsWith(`.${allowedDomain}`); - }); - } - async authorize( client: OAuthClientInformationFull, params: AuthorizationParams, diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index dd9376315b..9e5272b208 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -9,6 +9,7 @@ import type { ToolContext } from './tool-context'; import type { TokenTtlOptions } from './utils/token-ttl'; import type { Express } from 'express'; +import { normalizeDomainList } from '@forestadmin/forestadmin-client'; import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js'; import { metadataHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/metadata.js'; import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js'; @@ -44,7 +45,6 @@ import declareRequestActionFileUploadTool from './tools/request-action-file-uplo import declareTriggerWorkflowTool from './tools/trigger-workflow'; import declareUpdateTool from './tools/update'; import normalizeAgentUrl from './utils/normalize-agent-url'; -import normalizeDomainList from './utils/normalize-domain-list'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; import normalizeTokenTtl from './utils/token-ttl'; diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index 9fd3b0094e..5068e6538e 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -15,7 +15,10 @@ import MockServer from './test-utils/mock-server'; import ForestOAuthProvider from '../src/forest-oauth-provider'; jest.mock('jsonwebtoken'); -jest.mock('@forestadmin/forestadmin-client'); +jest.mock('@forestadmin/forestadmin-client', () => ({ + ...jest.createMockFromModule('@forestadmin/forestadmin-client'), + isClientAllowed: jest.requireActual('@forestadmin/forestadmin-client').isClientAllowed, +})); const mockCreateForestAdminClient = createForestAdminClient as jest.MockedFunction< typeof createForestAdminClient From 1a4fefc8ca7889c2462308cc25e6a8c20d3f0114 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 7 Oct 2026 10:51:46 +0200 Subject: [PATCH 04/11] feat(agent-bff): rename what the customer reads to Gateway API (#1979) --- packages/agent-bff/src/base-path.ts | 2 +- packages/agent-bff/src/build-bff.ts | 69 ++-- packages/agent-bff/src/cli-core.ts | 29 +- packages/agent-bff/src/cli-dispatch.ts | 82 +---- packages/agent-bff/src/config/env-config.ts | 90 +++-- .../src/config/missing-config-warning.ts | 10 +- .../agent-bff/src/cors/cors-middleware.ts | 10 +- packages/agent-bff/src/cors/per-key-origin.ts | 4 +- packages/agent-bff/src/docs/docs-page.ts | 10 +- packages/agent-bff/src/docs/docs-samples.ts | 2 +- packages/agent-bff/src/http/bff-http-error.ts | 2 +- .../agent-bff/src/http/bff-http-server.ts | 30 +- packages/agent-bff/src/http/health-route.ts | 29 +- .../src/http/version-header-middleware.ts | 8 +- packages/agent-bff/src/index.ts | 12 +- .../agent-bff/src/openapi/openapi-document.ts | 64 ++-- .../agent-bff/src/openapi/render-openapi.ts | 91 +++++ packages/agent-bff/src/openapi/schemas.ts | 20 +- .../agent-bff/src/openapi/unfolded-paths.ts | 2 +- packages/agent-bff/test/base-path.test.ts | 2 +- packages/agent-bff/test/build-bff.test.ts | 61 +++- packages/agent-bff/test/cli-shutdown.test.ts | 1 + .../agent-bff/test/config/env-config.test.ts | 88 +++++ .../test/cors/cors-middleware.test.ts | 13 + .../test/cors/per-key-origin.test.ts | 24 +- .../agent-bff/test/docs/docs-page.test.ts | 17 +- .../test/openapi/openapi-cli.test.ts | 15 +- .../test/openapi/openapi-document.test.ts | 27 +- .../openapi/openapi-mount-invariant.test.ts | 11 +- .../agent-bff/test/prebuilt-listener.test.ts | 313 ++++++++++++++++++ packages/agent/src/agent.ts | 1 + packages/agent/src/embedded-bff.ts | 74 ++++- packages/agent/src/gateway.ts | 5 +- .../agent/test/agent-bff-lifecycle.test.ts | 38 +++ .../agent/test/agent-gateway-wiring.test.ts | 2 +- .../bff/embedded-bff-config-labels.test.ts | 148 +++++++++ .../agent/test/bff/embedded-bff.e2e.test.ts | 22 +- 37 files changed, 1168 insertions(+), 260 deletions(-) create mode 100644 packages/agent-bff/src/openapi/render-openapi.ts create mode 100644 packages/agent-bff/test/prebuilt-listener.test.ts create mode 100644 packages/agent/test/bff/embedded-bff-config-labels.test.ts diff --git a/packages/agent-bff/src/base-path.ts b/packages/agent-bff/src/base-path.ts index 4f31808c6d..30f8f3305d 100644 --- a/packages/agent-bff/src/base-path.ts +++ b/packages/agent-bff/src/base-path.ts @@ -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).`, ); } diff --git a/packages/agent-bff/src/build-bff.ts b/packages/agent-bff/src/build-bff.ts index 459e6d60cf..0ae63c84b6 100644 --- a/packages/agent-bff/src/build-bff.ts +++ b/packages/agent-bff/src/build-bff.ts @@ -1,7 +1,8 @@ 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'; import type { UnfoldSource } from './openapi/unfolded-document'; @@ -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'; @@ -41,7 +43,7 @@ import createAccessLogMiddleware from './http/access-log-middleware'; import { unauthorized, unsupportedMediaType } from './http/bff-http-error'; import BODY_LIMIT, { AI_BODY_LIMIT } from './http/body-limit'; import createErrorMiddleware from './http/error-middleware'; -import createHealthRoute from './http/health-route'; +import createHealthRoute, { describeHealth } from './http/health-route'; import createVersionHeaderMiddleware from './http/version-header-middleware'; import createEnvironmentIdResolver, { tolerateEnvironmentIdFailure } from './oauth/environment-id'; import ForestServerClient from './oauth/forest-server-client'; @@ -89,6 +91,13 @@ export interface BuildBffOptions { * 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 { @@ -106,6 +115,7 @@ export interface Bff { * `timeoutMs` is the host's shutdown deadline; the returned descriptions name what it cut short. */ drainActivityLogs?: (timeoutMs?: number) => Promise; + health(): BffHealth; } const SESSION_TTL_SECONDS = 24 * 60 * 60; @@ -465,6 +475,7 @@ function buildAgentMiddlewares( basePath: string, transport: AgentTransport | undefined, metrics: Metrics | undefined, + allowedOriginsLabel = 'BFF_ALLOWED_ORIGINS', ): AgentEdge { const { forestAuthSecret, defaultTimezone } = config; @@ -491,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, @@ -542,24 +557,32 @@ export default async function buildBff({ dispatcher, metrics, allowedOAuthClients, + gatewayVersion, + labels = {}, + name, }: BuildBffOptions): Promise { + 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`.', ); } @@ -574,30 +597,31 @@ export default async function buildBff({ mountPath, transport, metrics, + label('BFF_ALLOWED_ORIGINS'), ); const agentMiddlewares = agentEdge.middlewares; const hasAgentEdge = agentMiddlewares.length > 0; const agentErrorMiddleware = hasAgentEdge ? [agentScoped(createErrorMiddleware({ logger }))] : []; const agentJsonOnlyGuard = hasAgentEdge ? [agentScoped(createJsonOnlyGuard())] : []; + // 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. + const health = describeHealth( + (dispatcher !== undefined && Boolean(config.forestAuthSecret)) || config.hasAllRequired, + version, + { + oauth: oauth.middlewares.length > 0, + ai: aiMiddlewares.length > 0, + cors: config.allowedOrigins.length > 0, + openapi: config.openapiEnabled && agentMiddlewares.length > 0, + }, + ); const middlewares = [ createAccessLogMiddleware({ logger, basePath: mountPath }), - createVersionHeaderMiddleware(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, - }, - }), + createVersionHeaderMiddleware(version, gatewayVersion), + createHealthRoute({ health }), createCorsMiddleware({ allowedOrigins: config.allowedOrigins, logger }), ...agentErrorMiddleware, ...agentJsonOnlyGuard, @@ -624,5 +648,6 @@ export default async function buildBff({ callback: app.callback(), invalidate: agentEdge.invalidate, drainActivityLogs: activityLogs && (timeoutMs => activityLogs.drain(timeoutMs)), + health: () => ({ ...health, configured: { ...health.configured } }), }; } diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index fcf149a352..94136e23b6 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -4,12 +4,16 @@ import createConsoleLogger from './adapters/console-logger'; import buildBff from './build-bff'; import { parseConfig } from './config/env-config'; import { extractErrorMessage } from './errors'; -import BFFHttpServer from './http/bff-http-server'; +import BFFHttpServer, { DEFAULT_SERVER_NAME } from './http/bff-http-server'; const SHUTDOWN_SIGNALS: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; let installedShutdownHandlers: { signal: NodeJS.Signals; handler: () => void }[] = []; +export interface Stoppable { + stop(): Promise; +} + /** * Routes a termination signal to `stop()`, which drains the activity-log transitions no connection * holds. Registered here rather than in the server: an embedded deployment does not own the process @@ -18,17 +22,32 @@ let installedShutdownHandlers: { signal: NodeJS.Signals; handler: () => void }[] * A process runs one BFF, so a second call replaces the handlers instead of adding a pair: the * signal must reach the server that is listening, and nothing else. */ -export function installShutdownHandlers(server: BFFHttpServer, logger: Logger): void { +export function installShutdownHandlers( + target: Stoppable, + logger: Logger, + { name = DEFAULT_SERVER_NAME }: { name?: string } = {}, +): void { for (const { signal, handler } of installedShutdownHandlers) { process.removeListener(signal, handler); } + let stopping = false; + installedShutdownHandlers = SHUTDOWN_SIGNALS.map(signal => { const handler = () => { - logger('Info', 'Stopping the Forest BFF', { signal }); + if (stopping) { + logger('Info', `Ignoring the signal: the ${name} is already stopping`, { signal }); + + return; + } + + stopping = true; + logger('Info', `Stopping the ${name}`, { signal }); - server.stop().catch(error => { - logger('Error', 'The Forest BFF did not stop cleanly', { + new Promise(resolve => { + resolve(target.stop()); + }).catch(error => { + logger('Error', `The ${name} did not stop cleanly`, { cause: extractErrorMessage(error), }); }); diff --git a/packages/agent-bff/src/cli-dispatch.ts b/packages/agent-bff/src/cli-dispatch.ts index f085f402c7..ebeee77a02 100644 --- a/packages/agent-bff/src/cli-dispatch.ts +++ b/packages/agent-bff/src/cli-dispatch.ts @@ -5,14 +5,9 @@ import { mkdirSync, writeFileSync } from 'fs'; import path from 'path'; import createConsoleLogger from './adapters/console-logger'; -import { AI_QUERY_ROUTE } from './ai/ai-routes-middleware'; -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'; -import buildUnfoldedDocument, { issueOpenApiAgentToken } from './openapi/unfolded-document'; -import { isFullyDegraded } from './openapi/unfolding'; +import renderOpenApi from './openapi/render-openapi'; import version from './version'; export const DEFAULT_OUTPUT_FILE = 'openapi.json'; @@ -45,81 +40,6 @@ export interface DispatchOutcome { server?: BFFHttpServer; } -const UNFOLD_VARS = ['FOREST_ENV_SECRET', 'FOREST_AUTH_SECRET', 'AGENT_URL'] as const; - -const NOTHING_TO_UNFOLD = `${UNFOLD_VARS.join(', ')} must all be set to unfold it`; - -function wantsUnfolding(env: NodeJS.ProcessEnv): boolean { - return UNFOLD_VARS.every(name => (env[name] ?? '').trim() !== ''); -} - -function publishesAiQuery(env: NodeJS.ProcessEnv, logger: Logger): boolean { - try { - return resolveOAuthConfig(parseConfig(env)) !== undefined; - } catch (error) { - const reason = extractErrorMessage(error); - - logger( - 'Warn', - `Omitting ${AI_QUERY_ROUTE} from the document: the configuration could not be read (${reason})`, - ); - - return false; - } -} - -/** - * Unfolds when the deployment is configured to be inspected, and emits the generic document when it - * is not configured at all — the command keeps working without configuration. A deployment that IS - * configured but whose schema cannot be read fails instead of quietly degrading: it asked for the - * unfolded document, and a generic one would look like a complete answer. - */ -export async function renderOpenApi(env: NodeJS.ProcessEnv, logger: Logger): Promise { - const authSecret = env.FOREST_AUTH_SECRET; - - // `parseConfig` validates the WHOLE server configuration, including settings the export has nothing - // to do with (HTTP_PORT, the OAuth keys, the default timezone). A deployment that never asked for an - // unfolded document must not see its export die on one of those, so the config is parsed only once - // 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 } - : undefined; - - const publicUrl = parsePublicUrl(env.BFF_PUBLIC_URL); - const hasAiQueryRoute = publishesAiQuery(env, logger); - - if (!unfoldable?.source) { - logger('Warn', `Emitting the generic OpenAPI document: ${NOTHING_TO_UNFOLD}`); - - return `${serializeOpenApi( - generateOpenApiDocument(version, { hasAiQueryRoute, publicUrl }), - )}\n`; - } - - const { source } = unfoldable; - const readModel = await source.store.getReadModel(); - const { document, unfolding } = await buildUnfoldedDocument( - source, - readModel, - () => issueOpenApiAgentToken(unfoldable.authSecret), - { version, hasAiQueryRoute, publicUrl }, - ); - - // Not one collection came back with its field set, so the agent was unreachable throughout. The - // paths are real but every field schema is free-form, which is not the document this command - // promises — a CI job regenerating it needs something to branch on, and the degraded notes buried - // in the descriptions are not it. One odd collection stays a warning, not a failure. - if (isFullyDegraded(unfolding)) { - throw new Error( - 'No collection could be described: the agent answered no capabilities call, so every field ' + - 'schema in the document would be free-form. Check AGENT_URL and the agent, then retry.', - ); - } - - return `${document}\n`; -} - function rejectCli(reason: string): DispatchOutcome { process.stderr.write(`${reason}\n${HINT}\n`); diff --git a/packages/agent-bff/src/config/env-config.ts b/packages/agent-bff/src/config/env-config.ts index 3cd0e7ab99..5d16d555d2 100644 --- a/packages/agent-bff/src/config/env-config.ts +++ b/packages/agent-bff/src/config/env-config.ts @@ -15,6 +15,29 @@ export const REQUIRED_KEYS = [ export type RequiredKey = (typeof REQUIRED_KEYS)[number]; +export const CONFIG_KEYS = [ + ...REQUIRED_KEYS, + 'BFF_PUBLIC_URL', + 'BFF_TOKEN_ENCRYPTION_KEY', + 'BFF_ALLOWED_ORIGINS', + 'BFF_DEFAULT_TIMEZONE', + 'BFF_AGENT_TIMEOUT_MS', + 'BFF_AI_TIMEOUT_MS', + 'BFF_OPENAPI_ENABLED', + 'BFF_RATE_LIMIT_MAX_REQUESTS', + 'BFF_RATE_LIMIT_WINDOW_MS', + 'HTTP_PORT', +] as const; + +export type ConfigKey = (typeof CONFIG_KEYS)[number]; + +export type ConfigLabels = Partial>; + +export const labeler = + (labels: ConfigLabels): ((key: ConfigKey) => string) => + key => + labels[key] ?? key; + export const DEFAULTS: Partial> = { FOREST_SERVER_URL: 'https://api.forestadmin.com', FOREST_APP_URL: 'https://app.forestadmin.com', @@ -81,8 +104,8 @@ function parseBoundedInteger( return parsed; } -function parsePort(raw?: string): number { - return parseBoundedInteger(raw, 'HTTP_PORT', { +function parsePort(raw: string | undefined, label: string): number { + return parseBoundedInteger(raw, label, { min: 0, max: MAX_PORT, fallback: DEFAULT_BFF_PORT, @@ -93,29 +116,25 @@ function isHttpUrl(value: string): boolean { return !/\s/.test(value) && HTTP_URL_SCHEMA.safeParse(value).success; } -export function parsePublicUrl(raw?: string): string | undefined { +export function parsePublicUrl(raw: string | undefined, label: 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.', - ); + throw new ConfigurationError(`Invalid configuration: ${label} 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.', + `Invalid configuration: ${label} 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.', - ); + throw new ConfigurationError(`Invalid configuration: ${label} must not carry credentials.`); } return url.href.replace(/\/+$/, ''); @@ -125,12 +144,12 @@ function isValidEncryptionKey(value: string): boolean { return BASE64_PATTERN.test(value) && Buffer.from(value, 'base64').length === ENCRYPTION_KEY_BYTES; } -function parseEncryptionKey(raw?: string): string | undefined { +function parseEncryptionKey(raw: string | undefined, label: string): string | undefined { const value = normalize(raw); if (value !== undefined && !isValidEncryptionKey(value)) { throw new ConfigurationError( - `Invalid configuration: BFF_TOKEN_ENCRYPTION_KEY must be base64-encoded and exactly ${ENCRYPTION_KEY_BYTES} bytes (AES-256).`, + `Invalid configuration: ${label} must be base64-encoded and exactly ${ENCRYPTION_KEY_BYTES} bytes (AES-256).`, ); } @@ -141,29 +160,26 @@ function parseTimeoutMs(raw: string | undefined, envName: string, defaultMs: num return parseBoundedInteger(raw, envName, { min: 1, max: MAX_TIMEOUT_MS, fallback: defaultMs }); } -function parseDefaultTimezone(raw?: string): string | undefined { +function parseDefaultTimezone(raw: string | undefined, label: string): string | undefined { const value = normalize(raw); if (value !== undefined && !isValidTimezone(value)) { - throw new ConfigurationError( - `Invalid configuration: BFF_DEFAULT_TIMEZONE must be a valid IANA timezone.`, - ); + throw new ConfigurationError(`Invalid configuration: ${label} must be a valid IANA timezone.`); } return value; } -function parseOpenApiEnabled(raw?: string): boolean { +function parseOpenApiEnabled(raw: string | undefined, label: string): boolean { const value = normalize(raw)?.trim().toLowerCase(); if (value === undefined || value === 'true') return true; if (value === 'false') return false; - throw new ConfigurationError( - 'Invalid configuration: BFF_OPENAPI_ENABLED must be a boolean (true/false).', - ); + throw new ConfigurationError(`Invalid configuration: ${label} must be a boolean (true/false).`); } -export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig { +export function parseConfig(env: NodeJS.ProcessEnv, labels: ConfigLabels = {}): BFFConfig { + const label = labeler(labels); const normalized = Object.fromEntries( REQUIRED_KEYS.map(key => [key, normalize(env[key]) ?? DEFAULTS[key]]), ) as Record; @@ -172,7 +188,9 @@ export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig { const value = normalized[key]; if (value !== undefined && !isHttpUrl(value)) { - throw new ConfigurationError(`Invalid configuration: ${key} must be a valid http(s) URL.`); + throw new ConfigurationError( + `Invalid configuration: ${label(key)} must be a valid http(s) URL.`, + ); } } @@ -180,11 +198,17 @@ export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig { REQUIRED_KEYS.map(key => [key, normalized[key] !== undefined]), ) as PresenceMap; - const tokenEncryptionKey = parseEncryptionKey(env.BFF_TOKEN_ENCRYPTION_KEY); + const tokenEncryptionKey = parseEncryptionKey( + env.BFF_TOKEN_ENCRYPTION_KEY, + label('BFF_TOKEN_ENCRYPTION_KEY'), + ); const { origins: allowedOrigins, invalid: invalidAllowedOrigins } = parseAllowedOrigins( env.BFF_ALLOWED_ORIGINS, ); - const defaultTimezone = parseDefaultTimezone(env.BFF_DEFAULT_TIMEZONE); + const defaultTimezone = parseDefaultTimezone( + env.BFF_DEFAULT_TIMEZONE, + label('BFF_DEFAULT_TIMEZONE'), + ); return { forestAuthSecret: normalized.FOREST_AUTH_SECRET, @@ -192,21 +216,25 @@ 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), + publicUrl: parsePublicUrl(env.BFF_PUBLIC_URL, label('BFF_PUBLIC_URL')), tokenEncryptionKey, allowedOrigins, invalidAllowedOrigins, defaultTimezone, agentTimeoutMs: parseTimeoutMs( env.BFF_AGENT_TIMEOUT_MS, - 'BFF_AGENT_TIMEOUT_MS', + label('BFF_AGENT_TIMEOUT_MS'), DEFAULT_AGENT_TIMEOUT_MS, ), - aiTimeoutMs: parseTimeoutMs(env.BFF_AI_TIMEOUT_MS, 'BFF_AI_TIMEOUT_MS', DEFAULT_AI_TIMEOUT_MS), - openapiEnabled: parseOpenApiEnabled(env.BFF_OPENAPI_ENABLED), + aiTimeoutMs: parseTimeoutMs( + env.BFF_AI_TIMEOUT_MS, + label('BFF_AI_TIMEOUT_MS'), + DEFAULT_AI_TIMEOUT_MS, + ), + openapiEnabled: parseOpenApiEnabled(env.BFF_OPENAPI_ENABLED, label('BFF_OPENAPI_ENABLED')), rateLimitMaxRequests: parseBoundedInteger( env.BFF_RATE_LIMIT_MAX_REQUESTS, - 'BFF_RATE_LIMIT_MAX_REQUESTS', + label('BFF_RATE_LIMIT_MAX_REQUESTS'), { min: 1, max: MAX_RATE_LIMIT_REQUESTS, @@ -215,14 +243,14 @@ export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig { ), rateLimitWindowMs: parseBoundedInteger( env.BFF_RATE_LIMIT_WINDOW_MS, - 'BFF_RATE_LIMIT_WINDOW_MS', + label('BFF_RATE_LIMIT_WINDOW_MS'), { min: MIN_RATE_LIMIT_WINDOW_MS, max: MAX_TIMEOUT_MS, fallback: DEFAULT_RATE_LIMIT_WINDOW_MS, }, ), - httpPort: parsePort(env.HTTP_PORT), + httpPort: parsePort(env.HTTP_PORT, label('HTTP_PORT')), presence, // 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 diff --git a/packages/agent-bff/src/config/missing-config-warning.ts b/packages/agent-bff/src/config/missing-config-warning.ts index 04daa0e227..6eea8ce77f 100644 --- a/packages/agent-bff/src/config/missing-config-warning.ts +++ b/packages/agent-bff/src/config/missing-config-warning.ts @@ -1,4 +1,4 @@ -import type { BFFConfig } from './env-config'; +import type { BFFConfig, ConfigKey, ConfigLabels } from './env-config'; import type { Logger } from '../ports/logger-port'; /** @@ -6,10 +6,14 @@ import type { Logger } from '../ports/logger-port'; * 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 { +export default function warnMissingConfig( + config: BFFConfig, + logger: Logger, + labels: ConfigLabels = {}, +): void { const missing = Object.entries(config.presence) .filter(([, present]) => !present) - .map(([key]) => key); + .map(([key]) => labels[key as ConfigKey] ?? key); if (missing.length === 0) return; diff --git a/packages/agent-bff/src/cors/cors-middleware.ts b/packages/agent-bff/src/cors/cors-middleware.ts index 7e35a398fd..6ee8f9d9c9 100644 --- a/packages/agent-bff/src/cors/cors-middleware.ts +++ b/packages/agent-bff/src/cors/cors-middleware.ts @@ -45,6 +45,9 @@ export const ALLOWED_HEADERS = 'Authorization, Content-Type, X-Forest-Timezone, X-Forest-Bff-Key, X-Request-Id, Forest-Projection'; export const PREFLIGHT_MAX_AGE_SECONDS = 600; +const EXPOSED_HEADERS = + 'Deprecation, Link, Retry-After, X-Forest-Gateway-Version, X-Forest-Bff-Version'; + /** * Shaped like every other BFF error so a consumer branches on `error.type`. Built here rather than * thrown: this middleware runs before the agent-scoped error middleware, so a throw would surface @@ -53,7 +56,7 @@ export const PREFLIGHT_MAX_AGE_SECONDS = 600; export const ORIGIN_NOT_ALLOWED = { type: 'origin_not_allowed', status: 403, - message: 'This origin is not allowed to call this BFF', + message: 'This origin is not allowed to call this Gateway API', } as const; export interface CorsMiddlewareOptions { @@ -72,7 +75,10 @@ export default function createCorsMiddleware({ const allowed = origin ? originAllowed(origin, allowedOrigins) : false; - if (allowed) ctx.set('Access-Control-Allow-Origin', origin); + if (allowed) { + ctx.set('Access-Control-Allow-Origin', origin); + ctx.set('Access-Control-Expose-Headers', EXPOSED_HEADERS); + } if (ctx.method === 'OPTIONS') { if (allowed) { diff --git a/packages/agent-bff/src/cors/per-key-origin.ts b/packages/agent-bff/src/cors/per-key-origin.ts index 04e726ab1a..d59ea75e25 100644 --- a/packages/agent-bff/src/cors/per-key-origin.ts +++ b/packages/agent-bff/src/cors/per-key-origin.ts @@ -10,6 +10,7 @@ import { originNotAllowed } from '../http/bff-http-error'; export interface PerKeyOriginMiddlewareOptions { logger: Logger; serverAllowedOrigins: string[]; + allowedOriginsLabel?: string; } // A key whose allowedOrigins share nothing with BFF_ALLOWED_ORIGINS can never serve a browser: this @@ -22,6 +23,7 @@ const MAX_ASSESSED_KEYS = 1000; export default function createPerKeyOriginMiddleware({ logger, serverAllowedOrigins, + allowedOriginsLabel = 'BFF_ALLOWED_ORIGINS', }: PerKeyOriginMiddlewareOptions): Middleware { const assessedKeys = new Set(); @@ -39,7 +41,7 @@ export default function createPerKeyOriginMiddleware({ assessedKeys.add(keyHash); if (canNeverPass) { - logger('Warn', 'BFF key origins are all outside BFF_ALLOWED_ORIGINS', { + logger('Warn', `BFF key origins are all outside ${allowedOriginsLabel}`, { keyHash, renderingId, keyOrigins: allowedOrigins.map(loggableOrigin), diff --git a/packages/agent-bff/src/docs/docs-page.ts b/packages/agent-bff/src/docs/docs-page.ts index 82fb20e388..fede1daf2c 100644 --- a/packages/agent-bff/src/docs/docs-page.ts +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -30,14 +30,14 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) - Forest BFF API + Forest Gateway API
Forest. - +
@@ -68,10 +68,10 @@ ${SAMPLES_SCRIPT} var error = body && body.error; if (error && error.type) { - return 'The BFF answered ' + status + ' ' + error.type + ': ' + (error.message || ''); + return 'The Gateway API answered ' + status + ' ' + error.type + ': ' + (error.message || ''); } - return 'The BFF answered ' + status + ': ' + JSON.stringify(body); + return 'The Gateway API answered ' + status + ': ' + JSON.stringify(body); } /** @@ -151,7 +151,7 @@ ${SAMPLES_SCRIPT} input.value = ''; if (key) load(key); - else show('A BFF API key is required: the document is never served unauthenticated.'); + else show('A Gateway API key is required: the document is never served unauthenticated.'); } button.addEventListener('click', unlockDocument); diff --git a/packages/agent-bff/src/docs/docs-samples.ts b/packages/agent-bff/src/docs/docs-samples.ts index 4dfc0af714..71a8590da3 100644 --- a/packages/agent-bff/src/docs/docs-samples.ts +++ b/packages/agent-bff/src/docs/docs-samples.ts @@ -329,7 +329,7 @@ const SAMPLES_SCRIPT = ` * The base every sample is built on, read off the document's own \`servers\` entry so the * snippets and the generated clients cannot disagree. An entry that already carries a scheme * and a host is the base as it stands; a bare prefix is appended to the origin the page was - * served from, and \`/\` means the BFF owns that origin root. + * served from, and \`/\` means the Gateway API owns that origin root. */ function sampleBase(spec) { var mount = (((spec.servers || [])[0]) || {}).url || '/'; diff --git a/packages/agent-bff/src/http/bff-http-error.ts b/packages/agent-bff/src/http/bff-http-error.ts index 5cba29cc5c..4e39b3c896 100644 --- a/packages/agent-bff/src/http/bff-http-error.ts +++ b/packages/agent-bff/src/http/bff-http-error.ts @@ -69,7 +69,7 @@ export function ambiguousCredentials( return new BffHttpError(400, 'ambiguous_credentials', message); } -export function sessionExpired(message = 'The BFF session has expired'): BffHttpError { +export function sessionExpired(message = 'The Gateway API session has expired'): BffHttpError { return new BffHttpError(401, 'session_expired', message); } diff --git a/packages/agent-bff/src/http/bff-http-server.ts b/packages/agent-bff/src/http/bff-http-server.ts index 14ff73a1e0..ceb5f573f9 100644 --- a/packages/agent-bff/src/http/bff-http-server.ts +++ b/packages/agent-bff/src/http/bff-http-server.ts @@ -7,7 +7,7 @@ import type { Middleware } from 'koa'; import http from 'http'; import Koa from 'koa'; -import createHealthRoute from './health-route'; +import createHealthRoute, { describeHealth } from './health-route'; import createVersionHeaderMiddleware from './version-header-middleware'; import createConsoleLogger from '../adapters/console-logger'; import warnMissingConfig from '../config/missing-config-warning'; @@ -15,10 +15,12 @@ import warnMissingConfig from '../config/missing-config-warning'; /** How long `stop()` waits for the open connections before it destroys them and drains anyway. */ export const SHUTDOWN_TIMEOUT_MS = 10_000; +export const DEFAULT_SERVER_NAME = 'Forest BFF'; + interface BFFHttpServerBaseOptions { port: number; - config: BFFConfig; logger?: Logger; + name?: string; /** Overrides `SHUTDOWN_TIMEOUT_MS`, for a host whose orchestrator grants a different grace. */ shutdownTimeoutMs?: number; /** @@ -29,9 +31,16 @@ interface BFFHttpServerBaseOptions { drainActivityLogs?: (timeoutMs?: number) => Promise; } -/** The server assembles its own Koa app around `/health` and the version header. */ +/** + * The server assembles its own Koa app around `/health` and the version header. + * + * @deprecated Pass a `callback` from `buildBff` instead. Removed at the next major. + */ interface AssembledOptions extends BFFHttpServerBaseOptions { + config: BFFConfig; + /** @deprecated Pass a `callback` from `buildBff` instead. Removed at the next major. */ version: string; + /** @deprecated Pass a `callback` from `buildBff` instead. Removed at the next major. */ middlewares?: Middleware[]; callback?: never; } @@ -42,6 +51,7 @@ interface AssembledOptions extends BFFHttpServerBaseOptions { * passing them would otherwise boot fine and 404 every one of its own routes. */ interface PrebuiltOptions extends BFFHttpServerBaseOptions { + config?: BFFConfig; callback: BffCallback; version?: never; middlewares?: never; @@ -57,11 +67,13 @@ export default class BFFHttpServer { private readonly handler: BffCallback; private readonly options: BFFHttpServerOptions; private readonly logger: Logger; + private readonly name: string; private server: Server | null = null; constructor(options: BFFHttpServerOptions) { this.options = options; this.logger = options.logger ?? createConsoleLogger(); + this.name = options.name ?? DEFAULT_SERVER_NAME; if (isPrebuilt(options)) { this.handler = options.callback; @@ -80,14 +92,12 @@ export default class BFFHttpServer { app.use(createVersionHeaderMiddleware(version)); app.use( createHealthRoute({ - version, - healthy: config.hasAllRequired, - configured: { + health: describeHealth(config.hasAllRequired, version, { oauth: Boolean(config.tokenEncryptionKey), ai: Boolean(config.tokenEncryptionKey), cors: config.allowedOrigins.length > 0, openapi: config.openapiEnabled, - }, + }), }), ); @@ -110,7 +120,7 @@ export default class BFFHttpServer { server.removeListener('error', onError); const address = server.address(); const port = typeof address === 'object' && address ? address.port : this.options.port; - this.logger('Info', 'Forest BFF started', { port }); + this.logger('Info', `${this.name} started`, { port }); resolve(); }; @@ -144,7 +154,7 @@ export default class BFFHttpServer { if (unfinished.length === 0) return; - this.logger('Warn', 'Stopped the Forest BFF with activity logs still in flight', { + this.logger('Warn', `Stopped the ${this.name} with activity logs still in flight`, { timeoutMs, unfinished, }); @@ -169,7 +179,7 @@ export default class BFFHttpServer { const timer = setTimeout(() => { settled = true; - this.logger('Warn', 'Forcing the Forest BFF shutdown: connections were still open', { + this.logger('Warn', `Forcing the ${this.name} shutdown: connections were still open`, { timeoutMs, }); server.closeAllConnections(); diff --git a/packages/agent-bff/src/http/health-route.ts b/packages/agent-bff/src/http/health-route.ts index d5cd3f3f05..353545e3d5 100644 --- a/packages/agent-bff/src/http/health-route.ts +++ b/packages/agent-bff/src/http/health-route.ts @@ -15,19 +15,26 @@ export interface HealthConfigured { openapi: boolean; } -export interface HealthRouteOptions { +export interface BffHealth { + status: 'ok' | 'degraded'; version: string; - /** Whether everything this deployment needs is configured. Embedded, it always is. */ - healthy: boolean; configured: HealthConfigured; } -export default function createHealthRoute({ - version, - healthy, - configured, -}: HealthRouteOptions): Middleware { - return async function health(ctx, next) { +export function describeHealth( + healthy: boolean, + version: string, + configured: HealthConfigured, +): BffHealth { + return { status: healthy ? 'ok' : 'degraded', version, configured }; +} + +export interface HealthRouteOptions { + health: BffHealth; +} + +export default function createHealthRoute({ health }: HealthRouteOptions): Middleware { + return async function healthRoute(ctx, next) { const isHealthRequest = (ctx.method === 'GET' || ctx.method === 'HEAD') && ctx.path === HEALTH_PATH; @@ -37,7 +44,7 @@ export default function createHealthRoute({ return; } - ctx.status = healthy ? 200 : 503; - ctx.body = { status: healthy ? 'ok' : 'degraded', version, configured }; + ctx.status = health.status === 'ok' ? 200 : 503; + ctx.body = health; }; } diff --git a/packages/agent-bff/src/http/version-header-middleware.ts b/packages/agent-bff/src/http/version-header-middleware.ts index c06696c64f..0eb6f3d190 100644 --- a/packages/agent-bff/src/http/version-header-middleware.ts +++ b/packages/agent-bff/src/http/version-header-middleware.ts @@ -2,9 +2,15 @@ import type { Middleware } from 'koa'; export const BFF_VERSION_HEADER = 'X-Forest-Bff-Version'; -export default function createVersionHeaderMiddleware(version: string): Middleware { +export const GATEWAY_VERSION_HEADER = 'X-Forest-Gateway-Version'; + +export default function createVersionHeaderMiddleware( + version: string, + gatewayVersion?: string, +): Middleware { return async function versionHeader(ctx, next) { ctx.set(BFF_VERSION_HEADER, version); + if (gatewayVersion) ctx.set(GATEWAY_VERSION_HEADER, gatewayVersion); await next(); }; diff --git a/packages/agent-bff/src/index.ts b/packages/agent-bff/src/index.ts index cc778e074d..bc3bcb16de 100644 --- a/packages/agent-bff/src/index.ts +++ b/packages/agent-bff/src/index.ts @@ -1,7 +1,14 @@ 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 type { + BFFConfig, + PresenceMap, + RequiredKey, + ConfigKey, + ConfigLabels, +} from './config/env-config'; +export { default as runCli, installShutdownHandlers } from './cli-core'; +export { default as renderOpenApi } from './openapi/render-openapi'; export { default as buildBff } from './build-bff'; export { default as createInProcessTransport, @@ -13,6 +20,7 @@ export type { AgentDispatchResponse, } from './agent/in-process-transport'; export type { Bff, BuildBffOptions, BffCallback } from './build-bff'; +export type { BffHealth, HealthConfigured } from './http/health-route'; export { ConfigurationError } from './errors'; export { default as DEFAULT_BFF_PORT } from './defaults'; export { default as createConsoleLogger } from './adapters/console-logger'; diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index c79963730c..8655020e4b 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -41,20 +41,20 @@ const ERROR_STATUSES: Record = { 401: 'Missing, invalid, or expired credentials', 403: 'The action needs approval before it runs (the body carries the approving roles), the Forest identity behind the API key is not allowed, the plan of the project does not include the Gateway API (type plan_feature_missing), the origin is not allowed for this key, the Forest server refused to write the activity log the request needs (type audit_not_authorized), or the agent refused the collection, relation, or action', 404: 'Unknown collection, relation, or action', - 413: `The request body exceeds the BFF limit of ${BODY_LIMIT}`, + 413: `The request body exceeds the Gateway API limit of ${BODY_LIMIT}`, 415: 'The request Content-Type is neither application/json nor an application/*+json type, including form-urlencoded, and is rejected with 415 instead of being silently dropped; a request carrying a body with no Content-Type at all is rejected the same way; or the declared character set cannot be decoded', 422: 'A field is unknown, not filterable, is a nested relation path, or an action value at execute is outside its enum or of the wrong type', - 429: `The BFF rate-limited the request, for one of two reasons: the caller identity exceeded its per-window budget, or the limiter is saturated and cannot open a window for a new identity. The \`details.cause\` field of the error body distinguishes them (\`${RATE_LIMIT_CAUSES.limitExceeded}\` or \`${RATE_LIMIT_CAUSES.limiterSaturated}\`), and Retry-After carries the seconds to wait. On data and action routes the agent may also rate-limit the request itself; that 429 is relayed with the agent's own payload as \`details\`, so it carries neither \`cause\` nor Retry-After — read them only when they are present rather than branching on their value`, - 500: 'The agent payload could not be mapped to the BFF contract, or the BFF hit an unexpected error', - 501: 'The BFF is running without an agent configured, so the proxy is not implemented', + 429: `The Gateway API rate-limited the request, for one of two reasons: the caller identity exceeded its per-window budget, or the limiter is saturated and cannot open a window for a new identity. The \`details.cause\` field of the error body distinguishes them (\`${RATE_LIMIT_CAUSES.limitExceeded}\` or \`${RATE_LIMIT_CAUSES.limiterSaturated}\`), and Retry-After carries the seconds to wait. On data and action routes the agent may also rate-limit the request itself; that 429 is relayed with the agent's own payload as \`details\`, so it carries neither \`cause\` nor Retry-After — read them only when they are present rather than branching on their value`, + 500: 'The agent payload could not be mapped to the Gateway API contract, or the Gateway API hit an unexpected error', + 501: 'The Gateway API is running without an agent configured, so the proxy is not implemented', 502: 'The agent refused the connection, its host could not be resolved, or the transport failed another way (a connection reset mid-flight, a socket hang up, a TLS failure) — it failed outright rather than running out of time', 503: 'The agent schema is unavailable, the agent returned a 5xx, the API key could not be resolved, the activity log an action execution must be recorded in could not be written, so the action was not run (type audit_unavailable), or the Forest permissions could not be fetched and no fresh cache was left (type permissions_unavailable)', - 504: 'The agent did not answer before the BFF timeout (BFF_AGENT_TIMEOUT_MS, 10s by default). The deadline is armed when the request starts, so at the default it also covers a host that accepts nothing and never resets the connection — raise the timeout past the OS connect timeout and that case reverts to 502', + 504: 'The agent did not answer before the Gateway API timeout (BFF_AGENT_TIMEOUT_MS, 10s by default). The deadline is armed when the request starts, so at the default it also covers a host that accepts nothing and never resets the connection — raise the timeout past the OS connect timeout and that case reverts to 502', }; const UNSUPPORTED_RESULT_DESCRIPTION = - 'Either the BFF runs without an agent configured, or the action returned a result shape the ' + - 'BFF cannot normalize. The second case carries no message field.'; + 'Either the Gateway API runs without an agent configured, or the action returned a result shape the ' + + 'Gateway API cannot normalize. The second case carries no message field.'; const ERROR_RESPONSE_REF = '#/components/schemas/ErrorResponse'; const MESSAGELESS_ERROR_RESPONSE_REF = '#/components/schemas/MessagelessErrorResponse'; @@ -76,15 +76,15 @@ const retryAfterHeader = (description: string) => ({ }); const RETRY_AFTER_HEADER = retryAfterHeader( - 'Seconds to wait before retrying. Set when the BFF could not reach the Forest server — an ' + + 'Seconds to wait before retrying. Set when the Gateway API could not reach the Forest server — an ' + 'unresolvable API key, or permissions it could not fetch.', ); const RETRY_AFTER_RATE_LIMIT_HEADER = retryAfterHeader( - 'Seconds until the BFF rate-limit window of the caller resets. When the limiter is ' + + 'Seconds until the Gateway API rate-limit window of the caller resets. When the limiter is ' + 'saturated the caller has no window yet, so the value is a lower bound instead: the ' + 'earliest reset among the identities currently holding one, after which a slot may free ' + - 'up. Present when the BFF itself emitted this 429; an agent 429 relayed from upstream ' + + 'up. Present when the Gateway API itself emitted this 429; an agent 429 relayed from upstream ' + 'carries no header.', ); @@ -209,30 +209,30 @@ const DATA_ERRORS = [ ]; const AI_RELAYED_OR_ENVELOPE = - 'Below 500 the BFF relays whatever JSON the Forest AI proxy answered, unchanged, so the authority ' + - 'on this body is the upstream contract and not the BFF one. When the upstream answer carried no ' + - 'JSON body, the BFF substitutes its own `{ error: { type, status, message } }` envelope at the ' + + 'Below 500 the Gateway API relays whatever JSON the Forest AI proxy answered, unchanged, so the authority ' + + 'on this body is the upstream contract and not the Gateway API one. When the upstream answer carried no ' + + 'JSON body, the Gateway API substitutes its own `{ error: { type, status, message } }` envelope at the ' + 'same status. A consumer must accept either shape.'; const AI_DUAL_SHAPED_ERRORS: Record = { - 400: `The BFF could not parse the body as JSON, or the Forest AI proxy rejected the query. ${AI_RELAYED_OR_ENVELOPE}`, - 401: `The BFF session is missing, invalid or expired, or the Forest server refused the access token this route forwards. ${AI_RELAYED_OR_ENVELOPE}`, + 400: `The Gateway API could not parse the body as JSON, or the Forest AI proxy rejected the query. ${AI_RELAYED_OR_ENVELOPE}`, + 401: `The Gateway API session is missing, invalid or expired, or the Forest server refused the access token this route forwards. ${AI_RELAYED_OR_ENVELOPE}`, 403: `The request presented an API key instead of a session (type oauth_required), or the Forest server refused the query. ${AI_RELAYED_OR_ENVELOPE}`, 413: `The request body exceeds the AI query limit of ${AI_BODY_LIMIT}, or the Forest AI proxy refused it as too large. ${AI_RELAYED_OR_ENVELOPE}`, - 415: `The request Content-Type is neither application/json nor an application/*+json type, or the request carries a body with no Content-Type at all, or it declares a character set the BFF cannot decode. ${AI_RELAYED_OR_ENVELOPE}`, - 429: `The BFF rate-limited the request (its own envelope, with Retry-After), or the Forest AI proxy rate-limited the query. ${AI_RELAYED_OR_ENVELOPE}`, + 415: `The request Content-Type is neither application/json nor an application/*+json type, or the request carries a body with no Content-Type at all, or it declares a character set the Gateway API cannot decode. ${AI_RELAYED_OR_ENVELOPE}`, + 429: `The Gateway API rate-limited the request (its own envelope, with Retry-After), or the Forest AI proxy rate-limited the query. ${AI_RELAYED_OR_ENVELOPE}`, }; const AI_ENVELOPE_ERRORS: Record = { - 500: 'The BFF hit an unexpected error, or the Forest server answered a 500 whose body is withheld', - 502: 'The Forest server could not be reached, or answered a status the BFF does not relay', + 500: 'The Gateway API hit an unexpected error, or the Forest server answered a 500 whose body is withheld', + 502: 'The Forest server could not be reached, or answered a status the Gateway API does not relay', 503: 'The Forest server reported itself unavailable', - 504: 'The Forest server did not answer the AI query before the BFF timeout', + 504: 'The Forest server did not answer the AI query before the Gateway API timeout', }; const AI_DEFAULT_DESCRIPTION = 'Any other status this relay can answer. Below 500 the body is the Forest AI proxy answer, ' + - 'relayed unchanged when it was JSON and replaced by the BFF ' + + 'relayed unchanged when it was JSON and replaced by the Gateway API ' + '`{ error: { type, status, message } }` envelope when it was not. At 500 and above the body is ' + 'always that envelope, so no upstream infrastructure detail reaches the caller. An upstream ' + 'answer below 400 that carries no JSON body is reported as 502, since there is nothing to relay.'; @@ -366,13 +366,13 @@ const TIMEZONE_HEADER = z .openapi({ param: { name: 'X-Forest-Timezone', in: 'header' }, description: - 'An IANA timezone. Takes precedence over the `timezone` body field; the BFF default ' + + 'An IANA timezone. Takes precedence over the `timezone` body field; the Gateway API default ' + 'applies when neither is sent.', }); const SHARED_DESCRIPTION = 'The timezone is resolved from the `X-Forest-Timezone` header first, then a `timezone` body ' + - 'field, then the BFF default when one is configured. A deployment without a default rejects a ' + + 'field, then the Gateway API default when one is configured. A deployment without a default rejects a ' + 'request carrying neither with 400 missing_timezone, so send one of the two to be safe. Agent ' + 'routes read application/json (and application/*+json) bodies only: any other Content-Type, ' + 'form-urlencoded included, is rejected with 415 instead of being read as an absent body, and ' + @@ -480,13 +480,13 @@ function registerDocumentPath(registry: OpenAPIRegistry, errorRefs: ErrorRespons 'API key — as they do on every data, action, context and permissions route; the AI-query ' + 'relay, where it is published, is the one exception and takes a session only. `HEAD` is ' + 'served identically. The ' + - 'answer is never cached (`Cache-Control: no-store`) and is regenerated when the BFF ' + + 'answer is never cached (`Cache-Control: no-store`) and is regenerated when the Gateway API ' + 'refreshes its schema, so a client can re-fetch it to pick up a new collection or field.', security: SECURITY, request: {}, responses: { 200: { - description: 'The OpenAPI document of this BFF', + description: 'The OpenAPI document of this Gateway API', content: { 'application/json': { schema: z.unknown() } }, }, 400: errorRefs.byStatus['400'], @@ -526,7 +526,7 @@ function registerAiQueryPath( summary: 'Relay an AI query to the Forest server', description: 'Relays the body unchanged to the Forest AI proxy, authenticated with the OAuth session ' + - 'held by the BFF. Requires a session: an API key is rejected with 403 oauth_required, ' + + 'held by the Gateway API. Requires a session: an API key is rejected with 403 oauth_required, ' + 'because only the OAuth flow yields the Forest access token this route forwards. The ' + 'environment and rendering are derived server-side, so sending forest-* headers has no ' + `effect. The body limit is ${AI_BODY_LIMIT} here rather than the ${BODY_LIMIT} of every ` + @@ -548,7 +548,7 @@ function registerAiQueryPath( }); } -const CONFIGURED_SERVER_DESCRIPTION = 'The public base URL of this BFF deployment.'; +const CONFIGURED_SERVER_DESCRIPTION = 'The public base URL of this Gateway API 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 @@ -564,7 +564,7 @@ export interface GenerateOpenApiDocumentOptions { hasAiQueryRoute?: boolean; /** The deployment's own external base URL, from `BFF_PUBLIC_URL`. Takes precedence. */ publicUrl?: string; - /** Where the BFF answers from the caller's point of view. Empty when it owns the origin root. */ + /** Where the Gateway API answers from the caller's point of view. Empty when it owns the origin root. */ basePath?: string; } @@ -589,8 +589,8 @@ export function generateOpenApiDocument( type: 'http', scheme: 'bearer', description: - 'Mode 1: the BFF session token issued after the OAuth login. Accepted on the context ' + - 'contract and on every data and action route, where the BFF mints the agent token from the ' + + 'Mode 1: the Gateway API session token issued after the OAuth login. Accepted on the context ' + + 'contract and on every data and action route, where the Gateway API mints the agent token from the ' + 'session. The session must carry a usable rendering, otherwise the request is rejected ' + 'with 401.', }); @@ -599,7 +599,7 @@ export function generateOpenApiDocument( in: 'header', name: 'X-Forest-Bff-Key', description: - 'Mode 2: a BFF API key. Never send both this and an Authorization header. A key created ' + + 'Mode 2: a Gateway API key. Never send both this and an Authorization header. A key created ' + 'with allowedOrigins restricts browser callers only: an Origin the client sends must be in ' + 'that list, else 403 origin_not_allowed, while a request with no Origin at all — curl, a ' + 'server-side client, CI, an empty Origin header counting as none — passes, because there ' + @@ -685,7 +685,7 @@ export function generateOpenApiDocument( )} collection.`, })), info: { - title: 'Forest BFF', + title: 'Forest Gateway API', version, license: { name: 'GPL-3.0', url: 'https://www.gnu.org/licenses/gpl-3.0.html' }, description: `${ diff --git a/packages/agent-bff/src/openapi/render-openapi.ts b/packages/agent-bff/src/openapi/render-openapi.ts new file mode 100644 index 0000000000..b77c60db37 --- /dev/null +++ b/packages/agent-bff/src/openapi/render-openapi.ts @@ -0,0 +1,91 @@ +import type { Logger } from '../ports/logger-port'; + +import { generateOpenApiDocument, serializeOpenApi } from './openapi-document'; +import buildUnfoldedDocument, { issueOpenApiAgentToken } from './unfolded-document'; +import { isFullyDegraded } from './unfolding'; +import { AI_QUERY_ROUTE } from '../ai/ai-routes-middleware'; +import normalizeBasePath from '../base-path'; +import { resolveOAuthConfig, resolveUnfoldSource } from '../build-bff'; +import { parseConfig, parsePublicUrl } from '../config/env-config'; +import { extractErrorMessage } from '../errors'; +import version from '../version'; + +const UNFOLD_VARS = ['FOREST_ENV_SECRET', 'FOREST_AUTH_SECRET', 'AGENT_URL'] as const; + +const NOTHING_TO_UNFOLD = `${UNFOLD_VARS.join(', ')} must all be set to unfold it`; + +function wantsUnfolding(env: NodeJS.ProcessEnv): boolean { + return UNFOLD_VARS.every(name => (env[name] ?? '').trim() !== ''); +} + +function publishesAiQuery(env: NodeJS.ProcessEnv, logger: Logger): boolean { + try { + return resolveOAuthConfig(parseConfig(env)) !== undefined; + } catch (error) { + const reason = extractErrorMessage(error); + + logger( + 'Warn', + `Omitting ${AI_QUERY_ROUTE} from the document: the configuration could not be read (${reason})`, + ); + + return false; + } +} + +/** + * Unfolds when the deployment is configured to be inspected, and emits the generic document when it + * is not configured at all — the command keeps working without configuration. A deployment that IS + * configured but whose schema cannot be read fails instead of quietly degrading: it asked for the + * unfolded document, and a generic one would look like a complete answer. + */ +export default async function renderOpenApi( + env: NodeJS.ProcessEnv, + logger: Logger, + options: { basePath?: string } = {}, +): Promise { + const basePath = normalizeBasePath(options.basePath); + const authSecret = env.FOREST_AUTH_SECRET; + + // `parseConfig` validates the WHOLE server configuration, including settings the export has nothing + // to do with (HTTP_PORT, the OAuth keys, the default timezone). A deployment that never asked for an + // unfolded document must not see its export die on one of those, so the config is parsed only once + // 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 } + : undefined; + + const publicUrl = parsePublicUrl(env.BFF_PUBLIC_URL, 'BFF_PUBLIC_URL'); + const hasAiQueryRoute = publishesAiQuery(env, logger); + + if (!unfoldable?.source) { + logger('Warn', `Emitting the generic OpenAPI document: ${NOTHING_TO_UNFOLD}`); + + return `${serializeOpenApi( + generateOpenApiDocument(version, { hasAiQueryRoute, publicUrl, basePath }), + )}\n`; + } + + const { source } = unfoldable; + const readModel = await source.store.getReadModel(); + const { document, unfolding } = await buildUnfoldedDocument( + source, + readModel, + () => issueOpenApiAgentToken(unfoldable.authSecret), + { version, hasAiQueryRoute, publicUrl, basePath }, + ); + + // Not one collection came back with its field set, so the agent was unreachable throughout. The + // paths are real but every field schema is free-form, which is not the document this command + // promises — a CI job regenerating it needs something to branch on, and the degraded notes buried + // in the descriptions are not it. One odd collection stays a warning, not a failure. + if (isFullyDegraded(unfolding)) { + throw new Error( + 'No collection could be described: the agent answered no capabilities call, so every field ' + + 'schema in the document would be free-form. Check AGENT_URL and the agent, then retry.', + ); + } + + return `${document}\n`; +} diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index d922431180..3b2c1a23d0 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -48,7 +48,7 @@ const ConditionTreeSchema: z.ZodType = z description: 'Either a leaf condition, a branch nesting more conditions, or the empty object, which is ' + 'how an absent filter is spelled. A branch must carry its ' + - '`aggregator`: the BFF forwards a branch without one, but the agent then parses it as ' + + '`aggregator`: the Gateway API forwards a branch without one, but the agent then parses it as ' + 'neither leaf nor branch and answers 400. On top-level list and count, nesting deeper ' + `than ${MAX_FILTER_DEPTH} levels is rejected and each field is checked against the ` + 'collection capabilities; relation list and count forward the filter without either check.', @@ -64,7 +64,7 @@ export const PageSchema = PageInput.openapi('Page', { `Any other offset is rejected with 400 invalid_request, as is a \`limit\` above \`${MAX_PAGE_LIMIT}\` ` + '(the maximum is not clamped silently). `limit` and `offset` are both ' + 'required once `page` is sent, but the object itself is optional on every list — and ' + - 'omitting it is not a request for the whole collection. The BFF then forwards no ' + + 'omitting it is not a request for the whole collection. The Gateway API then forwards no ' + 'pagination, so the agent applies its own default: the first page (offset 0), a limit ' + 'of 15 records on the Node agent. Anything past that page is silently missing. Send ' + '`page` and walk it to read a collection in full.', @@ -91,7 +91,7 @@ export const SearchSchema = SearchInput.openapi('Search', { 'collection — so a search reaches relation fields on its own, with no `searchExtended`, and ' + 'escapes the 422 relation_field_not_supported that the same path draws in `filter`, `sort` or ' + '`projection`. The agent resolves a relation named in a query against its own schema, so a ' + - 'query can filter on a column of a collection this BFF does not expose.', + 'query can filter on a column of a collection this Gateway API does not expose.', }); export const SearchExtendedSchema = SearchExtendedInput.openapi('SearchExtended', { @@ -231,7 +231,7 @@ const ActionResultSuccessSchema = z description: 'The action ran. `invalidated` names the relations of the acted-on collection whose ' + 'Related Data should be re-fetched — the agent fills it from ' + - '`resultBuilder.success(message, { invalidated })` and the BFF relays it from the agent ' + + '`resultBuilder.success(message, { invalidated })` and the Gateway API relays it from the agent ' + '`refresh.relationships`. `message` is the agent wording: an agent-nodejs success with ' + 'no message serializes the empty string, so null means the agent omitted `success` ' + 'entirely.', @@ -269,7 +269,7 @@ export const ActionResultSchema = z .openapi('ActionResult', { description: 'The normalized execute result, discriminated by `type`. These three are the only 200 ' + - 'bodies: a result the BFF cannot normalize answers 501 instead (see the execute 501 ' + + 'bodies: a result the Gateway API cannot normalize answers 501 instead (see the execute 501 ' + 'response), and a form the agent rejects answers 400 action_error.', }); @@ -381,7 +381,7 @@ const ContextFieldSchema = z.object({ .openapi({ description: 'Set only alongside `isPrimaryKey`, when the schema declared no primary key at all and ' + - 'the BFF derived one from a field named `id`. The key is then a GUESS: it is what ' + + 'the Gateway API derived one from a field named `id`. The key is then a GUESS: it is what ' + '`__forest.primaryKey` carries, but nothing confirms it is the column the records are ' + 'really keyed on, so a filter on it can answer 200 with no row. Use it to read record ' + 'identities, not to filter by identity.', @@ -445,11 +445,11 @@ export const ContextResponseSchema = z 'not covered either: they carry no `recordKey` and the same transform applies to them. ' + 'The document carries no rendering, project or team identity, and the only environment ' + 'datum is `meta.environmentId` below. It is served to both auth modes — an OAuth session ' + - 'and a BFF API key get the same document. It is NOT filtered by the caller permissions: ' + + 'and a Gateway API key get the same document. It is NOT filtered by the caller permissions: ' + 'it describes the whole exposed schema minus the endpoint-less actions, so cross it with ' + '`/agent/v1/permissions` to know what the caller may actually ' + - 'see. `meta.schemaRevision` increments whenever the BFF refreshes its schema, and resets ' + - 'when the BFF restarts. `meta.environmentId` is the environment the BFF resolved at boot ' + + 'see. `meta.schemaRevision` increments whenever the Gateway API refreshes its schema, and resets ' + + 'when the Gateway API restarts. `meta.environmentId` is the environment the Gateway API resolved at boot ' + 'from its own secret — it is telemetry, not a routing input, and it is absent when the ' + 'deployment runs without the OAuth configuration.', }); @@ -515,7 +515,7 @@ export const PermissionHintsSchema = z export const AiQueryRequestSchema = z.unknown().openapi('AiQueryRequest', { description: 'Passed through to the Forest AI proxy without validation or rewriting, so the authority on ' + - 'this shape is the upstream contract, not the BFF. It is left free-form on purpose: the BFF ' + + 'this shape is the upstream contract, not the Gateway API. It is left free-form on purpose: the Gateway API ' + 'enforces nothing here, and a shape published from this side would drift from the upstream ' + 'one nobody keeps it in sync with. In practice it is the OpenAI chat-completion body — ' + '`messages`, and optionally `tools`, `tool_choice`, `parallel_tool_calls`. A malformed body ' + diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index b46371cc6d..aee0dba1b7 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -485,7 +485,7 @@ function parentIdSchema( ...PARENT_ID_SHAPE, description: `The parent ${quoted(parent)} record id (${column}). A number or its ` + - 'string form are both accepted; the BFF forwards it to the agent as a string.', + 'string form are both accepted; the Gateway API forwards it to the agent as a string.', }; } diff --git a/packages/agent-bff/test/base-path.test.ts b/packages/agent-bff/test/base-path.test.ts index a35c2ebc66..fb5695e920 100644 --- a/packages/agent-bff/test/base-path.test.ts +++ b/packages/agent-bff/test/base-path.test.ts @@ -35,7 +35,7 @@ describe('normalizeBasePath', () => { ['a traversal', '/bff/../admin'], ])('should reject %s rather than publish a mount nobody serves', (_, input) => { expect(() => normalizeBasePath(input)).toThrow(ConfigurationError); - expect(() => normalizeBasePath(input)).toThrow(`Invalid BFF base path "${input}"`); + expect(() => normalizeBasePath(input)).toThrow(`Invalid Gateway API base path "${input}"`); }); }); }); diff --git a/packages/agent-bff/test/build-bff.test.ts b/packages/agent-bff/test/build-bff.test.ts index 751c36b4e2..902ca30d3d 100644 --- a/packages/agent-bff/test/build-bff.test.ts +++ b/packages/agent-bff/test/build-bff.test.ts @@ -179,7 +179,7 @@ describe('buildBff', () => { logger: noopLogger, basePath: 'https://bff.example.com', }), - ).rejects.toThrow('Invalid BFF base path "https://bff.example.com"'); + ).rejects.toThrow('Invalid Gateway API base path "https://bff.example.com"'); }); }); @@ -285,5 +285,64 @@ describe('buildBff', () => { { entries: ['*'] }, ); }); + + it('should name the label the caller passed instead of the env key', async () => { + const logger = jest.fn(); + + await buildBff({ + config: parseConfig({ ...VALID_ENV, BFF_ALLOWED_ORIGINS: 'https://ok.example.com,*' }), + logger, + labels: { BFF_ALLOWED_ORIGINS: 'api.allowedOrigins' }, + }); + + expect(logger).toHaveBeenCalledWith('Warn', 'Ignoring malformed api.allowedOrigins entries', { + entries: ['*'], + }); + }); + }); + + describe('boot warnings under a label map', () => { + it('should warn with the Gateway API noun and the option label when no origin is allowed', async () => { + const logger = jest.fn(); + + await buildBff({ + config: parseConfig(VALID_ENV), + logger, + labels: { BFF_ALLOWED_ORIGINS: 'api.allowedOrigins' }, + name: 'Gateway API', + }); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'No allowed origin: no browser can call this Gateway API. Set `api.allowedOrigins`.', + ); + }); + + it('should keep the standalone wording when no name is given', async () => { + const logger = jest.fn(); + + await buildBff({ config: parseConfig(VALID_ENV), logger }); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'No allowed origin: no browser can call this BFF. Set BFF_ALLOWED_ORIGINS, or `allowedOrigins`.', + ); + }); + + it('should name the labeled key in the missing-configuration warning', async () => { + const logger = jest.fn(); + + await buildBff({ + config: parseConfig({ ...VALID_ENV, FOREST_AUTH_SECRET: undefined }), + logger, + labels: { FOREST_AUTH_SECRET: 'authSecret' }, + }); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'Missing required configuration; /health will report degraded', + { missing: ['authSecret'] }, + ); + }); }); }); diff --git a/packages/agent-bff/test/cli-shutdown.test.ts b/packages/agent-bff/test/cli-shutdown.test.ts index 7b70234863..b0e77b20d2 100644 --- a/packages/agent-bff/test/cli-shutdown.test.ts +++ b/packages/agent-bff/test/cli-shutdown.test.ts @@ -48,6 +48,7 @@ describe('shutdown handlers', () => { installed[0].handler(); await expect(stop.mock.results[0].value).rejects.toBe(closeError); + await new Promise(setImmediate); expect(logger).toHaveBeenCalledWith('Error', 'The Forest BFF did not stop cleanly', { cause: 'close failed', diff --git a/packages/agent-bff/test/config/env-config.test.ts b/packages/agent-bff/test/config/env-config.test.ts index a1dc4af70a..0132f37980 100644 --- a/packages/agent-bff/test/config/env-config.test.ts +++ b/packages/agent-bff/test/config/env-config.test.ts @@ -1,3 +1,5 @@ +import type { ConfigLabels } from '../../src/config/env-config'; + import { DEFAULTS, DEFAULT_AGENT_TIMEOUT_MS, @@ -525,4 +527,90 @@ describe('parseConfig', () => { ); }); }); + + describe('with the addGateway({ api }) label map', () => { + const LABELS: ConfigLabels = { + FOREST_AUTH_SECRET: 'authSecret', + FOREST_ENV_SECRET: 'envSecret', + FOREST_SERVER_URL: 'forestServerUrl', + FOREST_APP_URL: 'forestAppUrl', + BFF_TOKEN_ENCRYPTION_KEY: 'api.tokenEncryptionKey', + BFF_ALLOWED_ORIGINS: 'api.allowedOrigins', + BFF_DEFAULT_TIMEZONE: 'api.defaultTimezone', + BFF_AGENT_TIMEOUT_MS: 'api.agentTimeoutMs', + BFF_AI_TIMEOUT_MS: 'api.aiTimeoutMs', + BFF_OPENAPI_ENABLED: 'api.openapiEnabled', + }; + + it('should name api.tokenEncryptionKey on an invalid key', () => { + expect(() => parseConfig({ ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: 'zz' }, LABELS)).toThrow( + 'Invalid configuration: api.tokenEncryptionKey must be base64-encoded and exactly 32 bytes (AES-256).', + ); + }); + + it('should name api.defaultTimezone on an invalid timezone', () => { + expect(() => + parseConfig({ ...VALID_ENV, BFF_DEFAULT_TIMEZONE: 'Nowhere/None' }, LABELS), + ).toThrow('Invalid configuration: api.defaultTimezone must be a valid IANA timezone.'); + }); + + it('should name api.agentTimeoutMs on an invalid timeout', () => { + expect(() => parseConfig({ ...VALID_ENV, BFF_AGENT_TIMEOUT_MS: 'abc' }, LABELS)).toThrow( + 'Invalid configuration: api.agentTimeoutMs must be an integer between 1 and 2147483647.', + ); + }); + + it('should name api.aiTimeoutMs on an invalid timeout', () => { + expect(() => parseConfig({ ...VALID_ENV, BFF_AI_TIMEOUT_MS: 'abc' }, LABELS)).toThrow( + 'Invalid configuration: api.aiTimeoutMs must be an integer between 1 and 2147483647.', + ); + }); + + it('should name api.openapiEnabled on an invalid boolean', () => { + expect(() => parseConfig({ ...VALID_ENV, BFF_OPENAPI_ENABLED: 'maybe' }, LABELS)).toThrow( + 'Invalid configuration: api.openapiEnabled must be a boolean (true/false).', + ); + }); + + it('should name forestServerUrl on an invalid url', () => { + expect(() => parseConfig({ ...VALID_ENV, FOREST_SERVER_URL: 'not-a-url' }, LABELS)).toThrow( + 'Invalid configuration: forestServerUrl must be a valid http(s) URL.', + ); + }); + + it('should name a neutral label on an invalid public url, uncovered by the gateway map', () => { + expect(() => + parseConfig( + { ...VALID_ENV, BFF_PUBLIC_URL: 'not-a-url' }, + { BFF_PUBLIC_URL: 'x.publicUrl' }, + ), + ).toThrow('Invalid configuration: x.publicUrl must be a valid http(s) URL.'); + }); + + it('should name a neutral label on an invalid rate limit, uncovered by the gateway map', () => { + expect(() => + parseConfig( + { ...VALID_ENV, BFF_RATE_LIMIT_MAX_REQUESTS: 'abc' }, + { BFF_RATE_LIMIT_MAX_REQUESTS: 'x.rateLimitMaxRequests' }, + ), + ).toThrow( + 'Invalid configuration: x.rateLimitMaxRequests must be an integer between 1 and 10000.', + ); + }); + + it('should name a neutral label on an invalid port, uncovered by the gateway map', () => { + expect(() => + parseConfig({ ...VALID_ENV, HTTP_PORT: '99999' }, { HTTP_PORT: 'x.port' }), + ).toThrow('Invalid configuration: x.port must be an integer between 0 and 65535.'); + }); + + it('should keep the env key in every message when no map is given', () => { + expect(() => parseConfig({ ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: 'zz' })).toThrow( + 'Invalid configuration: BFF_TOKEN_ENCRYPTION_KEY must be base64-encoded and exactly 32 bytes (AES-256).', + ); + expect(() => parseConfig({ ...VALID_ENV, HTTP_PORT: '99999' })).toThrow( + 'Invalid configuration: HTTP_PORT must be an integer between 0 and 65535.', + ); + }); + }); }); diff --git a/packages/agent-bff/test/cors/cors-middleware.test.ts b/packages/agent-bff/test/cors/cors-middleware.test.ts index dff0c15088..f737d69a75 100644 --- a/packages/agent-bff/test/cors/cors-middleware.test.ts +++ b/packages/agent-bff/test/cors/cors-middleware.test.ts @@ -38,6 +38,16 @@ describe('cors middleware (layer 1)', () => { expect(response.headers.vary).toContain('Origin'); }); + it('exposes the deprecation and version headers to browser JS', async () => { + const { app } = buildApp(); + + const response = await request(app.callback()).get('/agent/x').set('Origin', ALLOWED); + + expect(response.headers['access-control-expose-headers']).toBe( + 'Deprecation, Link, Retry-After, X-Forest-Gateway-Version, X-Forest-Bff-Version', + ); + }); + it('matches an allow-listed origin regardless of the default port', async () => { const { app } = buildApp(); @@ -58,6 +68,9 @@ describe('cors middleware (layer 1)', () => { expect(response.status).toBe(403); expect(response.body.error).toMatchObject({ type: 'origin_not_allowed', status: 403 }); + expect(response.body.error.message).toBe( + 'This origin is not allowed to call this Gateway API', + ); expect(response.headers['access-control-allow-origin']).toBeUndefined(); // Le point du refus : sans lui la requête s'exécutait, et seul le navigateur jetait la // réponse — donc une liste était lue et une action exécutée pour une origine non autorisée. diff --git a/packages/agent-bff/test/cors/per-key-origin.test.ts b/packages/agent-bff/test/cors/per-key-origin.test.ts index cb02406548..35b779beb0 100644 --- a/packages/agent-bff/test/cors/per-key-origin.test.ts +++ b/packages/agent-bff/test/cors/per-key-origin.test.ts @@ -15,6 +15,7 @@ function buildApp( allowedOrigins?: string[], logger: Logger = () => undefined, serverAllowedOrigins: string[] = [], + allowedOriginsLabel?: string, ) { const app = new Koa(); app.silent = true; @@ -26,7 +27,7 @@ function buildApp( await next(); }); - app.use(createPerKeyOriginMiddleware({ logger, serverAllowedOrigins })); + app.use(createPerKeyOriginMiddleware({ logger, serverAllowedOrigins, allowedOriginsLabel })); app.use(async ctx => { ctx.status = 200; ctx.body = { reached: true }; @@ -218,6 +219,27 @@ describe('per-key origin middleware (layer 2)', () => { ); }); + it('names the option label the gateway operator wrote, when one is given', async () => { + const logger = jest.fn(); + + await request( + buildApp(['https://a.com'], logger, ['https://b.com'], 'api.allowedOrigins').callback(), + ) + .get('/agent/x') + .set('Origin', 'https://a.com') + .set(BFF_KEY_HEADER, RAW_KEY); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'BFF key origins are all outside api.allowedOrigins', + { + keyHash: fingerprintApiKey(RAW_KEY), + renderingId: RENDERING_ID, + keyOrigins: ['https://a.com'], + }, + ); + }); + it('stays silent when one key origin is on the server allow-list', async () => { const logger = jest.fn(); diff --git a/packages/agent-bff/test/docs/docs-page.test.ts b/packages/agent-bff/test/docs/docs-page.test.ts index 1b012a47b3..dec9a04e65 100644 --- a/packages/agent-bff/test/docs/docs-page.test.ts +++ b/packages/agent-bff/test/docs/docs-page.test.ts @@ -178,6 +178,19 @@ const API_KEY_SPEC = { }, }; +describe('docs page shell', () => { + it('should say Gateway API, not BFF, in the title, the key label and the unlock hint', () => { + const html = renderDocsPage(DOCUMENT_PATH, BUNDLE_PATH); + + expect(html).toContain('Forest Gateway API'); + expect(html).toContain(''); + expect(html).toContain( + 'A Gateway API key is required: the document is never served unauthenticated.', + ); + expect(html).not.toMatch(/\bBFF\b/); + }); +}); + describe('docs page script', () => { describe('when a submission is abandoned for another one', () => { it('should ignore the abandoned error, since it did not come from the key on screen', async () => { @@ -263,7 +276,7 @@ describe('docs page script', () => { }); await page.flush(); - expect(page.errorText()).toBe('The BFF answered 401 unauthorized: Key revoked'); + expect(page.errorText()).toBe('The Gateway API answered 401 unauthorized: Key revoked'); expect(page.redocInit).not.toHaveBeenCalled(); }); @@ -276,7 +289,7 @@ describe('docs page script', () => { expect(page.redocInit).not.toHaveBeenCalled(); expect(page.errorText()).toBe( - 'The BFF answered 200 unreadable_response: gateway', + 'The Gateway API answered 200 unreadable_response: gateway', ); }); diff --git a/packages/agent-bff/test/openapi/openapi-cli.test.ts b/packages/agent-bff/test/openapi/openapi-cli.test.ts index 111a8c0226..3d316cb317 100644 --- a/packages/agent-bff/test/openapi/openapi-cli.test.ts +++ b/packages/agent-bff/test/openapi/openapi-cli.test.ts @@ -8,14 +8,10 @@ import { tmpdir } from 'os'; import path from 'path'; import request from 'supertest'; -import dispatchCli, { - DEFAULT_OUTPUT_FILE, - HINT, - USAGE, - renderOpenApi, -} from '../../src/cli-dispatch'; +import dispatchCli, { DEFAULT_OUTPUT_FILE, 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'; import version from '../../src/version'; import { action, collection, column, relation } from '../read-model/fixtures'; @@ -246,6 +242,13 @@ describe('renderOpenApi', () => { expect(document.servers[0].url).toBe('https://bff.example.com'); }); + it('should carry basePath in the unfolded export', async () => { + const document = JSON.parse(await renderOpenApi(VALID_ENV, noopLogger, { basePath: '/api' })); + + expect(document.info.description).toContain('Paths are unfolded'); + expect(document.servers[0].url).toBe('/api'); + }); + 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/, diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index d2ca3fcaa8..85076b179b 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -14,6 +14,25 @@ import { DISPLAY_HINT_FINALITY } from '../../src/permissions/build-permission-hi const document = generateOpenApiDocument('9.9.9', { hasAiQueryRoute: true }); const schemas = document.components?.schemas as Record>; +it('should title the document Forest Gateway API', () => { + expect(document.info.title).toBe('Forest Gateway API'); +}); + +it('should describe the security schemes as Gateway API credentials', () => { + const schemes = document.components?.securitySchemes as Record; + + expect(schemes.bffSession.description).toContain('the Gateway API session token'); + expect(schemes.bffApiKey.description).toContain('a Gateway API key'); + expect(schemes.bffSession.description).not.toContain('BFF'); + expect(schemes.bffApiKey.description).not.toContain('BFF'); +}); + +it('should leave no product-noun BFF in the document outside env-var key names', () => { + const withoutEnvKeys = JSON.stringify(document).replace(/BFF_[A-Z_]+/g, ''); + + expect(withoutEnvKeys).not.toMatch(/\bBFF\b/); +}); + type ResolvedResponse = { description: string; headers?: Record; @@ -470,7 +489,7 @@ describe('generateOpenApiDocument', () => { expect(fallback.content?.['application/json'].schema).toEqual({}); expect(fallback.description).toContain('relayed unchanged when it was JSON'); - expect(fallback.description).toContain('replaced by the BFF'); + expect(fallback.description).toContain('replaced by the Gateway API'); expect(fallback.description).toContain( 'below 400 that carries no JSON body is reported as 502', ); @@ -590,13 +609,13 @@ describe('generateOpenApiDocument', () => { }); it('should credit the BFF itself with the 429, not only the agent', () => { - expect(listResponses()['429'].description).toContain('BFF rate-limited'); + expect(listResponses()['429'].description).toContain('Gateway API rate-limited'); }); it('should declare the 429 on the context route, which the same limiter answers', () => { const context = responsesOf(`${ROUTE_PREFIX}/context`); - expect(context['429'].description).toContain('BFF rate-limited'); + expect(context['429'].description).toContain('Gateway API rate-limited'); expect(Object.keys(context['429'].headers ?? {})).toEqual(['Retry-After']); }); @@ -607,7 +626,7 @@ describe('generateOpenApiDocument', () => { }; expect(Object.keys(ai['429'].headers ?? {})).toEqual(['Retry-After']); - expect(ai['429'].description).toContain('BFF rate-limited'); + expect(ai['429'].description).toContain('Gateway API rate-limited'); expect(header['Retry-After']?.description).toContain('rate-limit'); }); diff --git a/packages/agent-bff/test/openapi/openapi-mount-invariant.test.ts b/packages/agent-bff/test/openapi/openapi-mount-invariant.test.ts index 5bdd5bf5b8..c2dbc29ffb 100644 --- a/packages/agent-bff/test/openapi/openapi-mount-invariant.test.ts +++ b/packages/agent-bff/test/openapi/openapi-mount-invariant.test.ts @@ -9,7 +9,8 @@ const OPENAPI_MODULE_PREFIX = 'openapi/'; const ALLOWED_OPENAPI_IMPORTS: Record = { 'build-bff.ts': ['openapi/openapi-routes', 'openapi/unfolded-document'], - 'cli-dispatch.ts': ['openapi/openapi-document', 'openapi/unfolded-document', 'openapi/unfolding'], + 'cli-dispatch.ts': ['openapi/render-openapi'], + 'index.ts': ['openapi/render-openapi'], }; function sourceFiles(): string[] { @@ -51,14 +52,16 @@ describe('the OpenAPI document mount point', () => { }); describe('when a module outside src/openapi reaches into it', () => { - it('should only be the two known mount points, since any other one could serve the document off /agent', () => { + it('should only be the known mount points, since any other one could serve the document off /agent', () => { expect(openapiImportsOutsideTheOpenapiDir()).toEqual(ALLOWED_OPENAPI_IMPORTS); }); }); describe('when the package public surface is read', () => { - it('should expose nothing OpenAPI-related, since a consumer could mount it off /agent', () => { - expect(Object.keys(publicApi).filter(name => /openapi/i.test(name))).toEqual([]); + it('should expose only the string renderer, nothing a consumer could mount off /agent', () => { + expect(Object.keys(publicApi).filter(name => /openapi/i.test(name))).toEqual([ + 'renderOpenApi', + ]); }); }); }); diff --git a/packages/agent-bff/test/prebuilt-listener.test.ts b/packages/agent-bff/test/prebuilt-listener.test.ts new file mode 100644 index 0000000000..e386c12852 --- /dev/null +++ b/packages/agent-bff/test/prebuilt-listener.test.ts @@ -0,0 +1,313 @@ +import type { Logger } from '../src/index'; + +import http from 'http'; +import request from 'supertest'; + +import { + BFFHttpServer, + buildBff, + installShutdownHandlers, + parseConfig, + renderOpenApi, + version, +} from '../src/index'; +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 GATEWAY_VERSION = '7.7.7'; + +const SIGNALS: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; + +const noopLogger: Logger = () => undefined; + +function requestAnything(port: number): Promise<{ status?: number; body: string }> { + return new Promise((resolve, reject) => { + http + .get(`http://127.0.0.1:${port}/anything`, res => { + let body = ''; + res.on('data', chunk => { + body += chunk; + }); + res.on('end', () => resolve({ status: res.statusCode, body })); + }) + .on('error', reject); + }); +} + +describe('prebuilt listener surface of the package entry point', () => { + restoreFetchAfterEach(); + + beforeEach(() => stubEnvironmentIdFetch()); + + describe('BFFHttpServer with a prebuilt callback and no config', () => { + let server: BFFHttpServer | undefined; + + afterEach(async () => { + await server?.stop(); + server = undefined; + }); + + it('should listen and serve the callback', async () => { + let port = 0; + server = new BFFHttpServer({ + port: 0, + logger: (_level, _message, context) => { + port = Number(context?.port); + }, + callback: (_req, res) => { + res.statusCode = 418; + res.end('teapot'); + }, + }); + + await server.start(); + const response = await requestAnything(port); + + expect(response).toEqual({ status: 418, body: 'teapot' }); + }); + }); + + describe('BFFHttpServer name', () => { + async function startAndStop(name?: string): Promise { + const messages: string[] = []; + + const logger: Logger = (_level, message) => { + messages.push(message); + }; + + const server = new BFFHttpServer({ + port: 0, + logger, + callback: (_req, res) => { + res.end(); + }, + drainActivityLogs: async () => ['transition'], + ...(name ? { name } : {}), + }); + + await server.start(); + await server.stop(); + + return messages; + } + + it('should log the given name when it starts and stops', async () => { + const messages = await startAndStop('Forest Gateway'); + + expect(messages).toEqual([ + 'Forest Gateway started', + 'Stopped the Forest Gateway with activity logs still in flight', + ]); + }); + + it('should log "Forest BFF" when no name is given, which the image smoke test greps', async () => { + const messages = await startAndStop(); + + expect(messages).toEqual([ + 'Forest BFF started', + 'Stopped the Forest BFF with activity logs still in flight', + ]); + }); + }); + + describe('buildBff health()', () => { + it.each([ + ['healthy', VALID_ENV, 200], + ['degraded', { ...VALID_ENV, AGENT_URL: undefined }, 503], + ['key-only', { ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: undefined }, 200], + ])( + 'should equal the body of GET /health and the status of HEAD /health on a %s config', + async (_label, env, httpStatus) => { + const bff = await buildBff({ config: parseConfig(env), logger: noopLogger }); + + const get = await request(bff.callback).get('/health'); + const head = await request(bff.callback).head('/health'); + + expect(bff.health()).toEqual(get.body); + expect(Object.keys(get.body)).toEqual(['status', 'version', 'configured']); + expect(get.text).toBe(JSON.stringify(bff.health())); + expect(get.status).toBe(httpStatus); + expect(head.status).toBe(httpStatus); + }, + ); + }); + + describe('buildBff health() result', () => { + it('should not let a caller change what /health answers', async () => { + const bff = await buildBff({ config: parseConfig(VALID_ENV), logger: noopLogger }); + + const exposed = bff.health(); + exposed.status = 'degraded'; + exposed.configured.oauth = false; + const response = await request(bff.callback).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(expect.objectContaining({ status: 'ok' })); + expect(response.body.configured.oauth).toBe(true); + }); + }); + + describe('installShutdownHandlers', () => { + let installed: { signal: NodeJS.Signals; handler: () => void }[] = []; + + function install(stop: () => Promise, logger: Logger = noopLogger): void { + installShutdownHandlers({ stop }, logger); + installed = SIGNALS.map(signal => ({ + signal, + handler: process.listeners(signal).at(-1) as () => void, + })); + } + + function send(signal: NodeJS.Signals): void { + installed.find(entry => entry.signal === signal)?.handler(); + } + + afterEach(() => { + for (const { signal, handler } of installed) process.removeListener(signal, handler); + installed = []; + }); + + it.each([ + ['SIGTERM', 'SIGTERM'], + ['SIGTERM', 'SIGINT'], + ] as [NodeJS.Signals, NodeJS.Signals][])( + 'should call stop() once on %s then %s', + (first, second) => { + const stop = jest.fn(() => new Promise(jest.fn())); + + install(stop); + send(first); + send(second); + + expect(stop).toHaveBeenCalledTimes(1); + }, + ); + + it('should still call stop() once when the second signal lands after it resolved', async () => { + const stop = jest.fn(async () => undefined); + + install(stop); + send('SIGTERM'); + await stop.mock.results[0].value; + send('SIGINT'); + + expect(stop).toHaveBeenCalledTimes(1); + }); + + it('should log a stop() that throws synchronously instead of letting it escape', async () => { + const logger = jest.fn(); + + install(() => { + throw new Error('boom'); + }, logger as unknown as Logger); + + expect(() => send('SIGTERM')).not.toThrow(); + await new Promise(setImmediate); + expect(logger).toHaveBeenCalledWith('Error', 'The Forest BFF did not stop cleanly', { + cause: 'boom', + }); + }); + + it('should log the signal it ignores while stopping', () => { + const logger = jest.fn(); + + install(() => new Promise(jest.fn()), logger as unknown as Logger); + send('SIGTERM'); + send('SIGINT'); + + expect(logger).toHaveBeenCalledWith( + 'Info', + 'Ignoring the signal: the Forest BFF is already stopping', + { signal: 'SIGINT' }, + ); + }); + + it('should log the given name', () => { + const logger = jest.fn(); + + installShutdownHandlers( + { stop: () => new Promise(jest.fn()) }, + logger as unknown as Logger, + { name: 'Forest Gateway' }, + ); + installed = SIGNALS.map(signal => ({ + signal, + handler: process.listeners(signal).at(-1) as () => void, + })); + send('SIGTERM'); + + expect(logger).toHaveBeenCalledWith('Info', 'Stopping the Forest Gateway', { + signal: 'SIGTERM', + }); + }); + }); + + describe('buildBff gatewayVersion', () => { + it('should set X-Forest-Gateway-Version once and leave X-Forest-Bff-Version alone', async () => { + const { callback } = await buildBff({ + config: parseConfig(VALID_ENV), + logger: noopLogger, + gatewayVersion: GATEWAY_VERSION, + }); + + for (const path of ['/health', '/unknown-path', '/agent/v1/companies/list']) { + // eslint-disable-next-line no-await-in-loop + const response = await request(callback).get(path); + + expect(response.headers['x-forest-gateway-version']).toBe(GATEWAY_VERSION); + expect(response.headers['x-forest-bff-version']).toBe(version); + } + }); + + it('should set no X-Forest-Gateway-Version for an empty gatewayVersion', async () => { + const { callback } = await buildBff({ + config: parseConfig(VALID_ENV), + logger: noopLogger, + gatewayVersion: '', + }); + + const response = await request(callback).get('/health'); + + expect(response.headers).not.toHaveProperty('x-forest-gateway-version'); + }); + + it('should set no X-Forest-Gateway-Version without the option', async () => { + const { callback } = await buildBff({ config: parseConfig(VALID_ENV), logger: noopLogger }); + + const response = await request(callback).get('/health'); + + expect(response.headers).not.toHaveProperty('x-forest-gateway-version'); + expect(response.headers['x-forest-bff-version']).toBe(version); + }); + }); + + describe('renderOpenApi', () => { + it('should emit basePath as the relative server when no public url is set', async () => { + const document = JSON.parse(await renderOpenApi({}, noopLogger, { basePath: '/api' })); + + expect(document.servers[0].url).toBe('/api'); + }); + + it('should emit the root-relative server forest-bff openapi emits when given no option', async () => { + const document = JSON.parse(await renderOpenApi({}, noopLogger)); + + expect(document.servers).toEqual([ + { + url: '/', + 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.', + }, + ]); + }); + }); +}); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 1c6aff82ef..fcb6286ad7 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -577,6 +577,7 @@ export default class Agent extends FrameworkMounter requiredVersion: AGENT_BFF_PEER_VERSION, skipIpWhitelistWarning: Boolean(this.gateway?.services.mcp), allowedOAuthClients: this.gateway?.allowedOAuthClients, + gateway: true, }); return this.embeddedBff; diff --git a/packages/agent/src/embedded-bff.ts b/packages/agent/src/embedded-bff.ts index bfa995288d..397c25fb4a 100644 --- a/packages/agent/src/embedded-bff.ts +++ b/packages/agent/src/embedded-bff.ts @@ -1,14 +1,36 @@ import type { AgentOptionsWithDefaults, BffEmbedOptions, HttpCallback } from './types'; -import type { AgentDispatcher, BFFConfig, Bff, claimsBffPath } from '@forestadmin/agent-bff'; +import type { + AgentDispatcher, + BFFConfig, + Bff, + ConfigLabels, + claimsBffPath, +} from '@forestadmin/agent-bff'; + +import { AGENT_VERSION } from './gateway'; const BFF_PACKAGE = '@forestadmin/agent-bff'; +export const GATEWAY_API_CONFIG_LABELS: ConfigLabels = { + FOREST_AUTH_SECRET: 'authSecret', + FOREST_ENV_SECRET: 'envSecret', + FOREST_SERVER_URL: 'forestServerUrl', + FOREST_APP_URL: 'forestAppUrl', + BFF_TOKEN_ENCRYPTION_KEY: 'api.tokenEncryptionKey', + BFF_ALLOWED_ORIGINS: 'api.allowedOrigins', + BFF_DEFAULT_TIMEZONE: 'api.defaultTimezone', + BFF_AGENT_TIMEOUT_MS: 'api.agentTimeoutMs', + BFF_AI_TIMEOUT_MS: 'api.aiTimeoutMs', + BFF_OPENAPI_ENABLED: 'api.openapiEnabled', +}; + export type EmbeddedBffMount = { prefix: string; name?: string; requiredVersion?: string; skipIpWhitelistWarning?: boolean; allowedOAuthClients?: string[]; + gateway?: boolean; }; /** How long `stop()` waits for the activity-log writes no connection holds. */ @@ -20,8 +42,8 @@ export const SHUTDOWN_TIMEOUT_MS = 10_000; * hand — their `message` and `stack` are not enumerable, so `JSON.stringify` alone turns the one * value worth logging into `{}`. Never throws — logging must not break a request. */ -function formatLog(message: string, context?: Record): string { - if (!context || Object.keys(context).length === 0) return `[BFF] ${message}`; +function formatLog(tag: string, message: string, context?: Record): string { + if (!context || Object.keys(context).length === 0) return `${tag} ${message}`; try { const serialized = JSON.stringify(context, (_key, value) => @@ -30,9 +52,9 @@ function formatLog(message: string, context?: Record): string { : value, ); - return `[BFF] ${message} ${serialized}`; + return `${tag} ${message} ${serialized}`; } catch { - return `[BFF] ${message} [unserializable context]`; + return `${tag} ${message} [unserializable context]`; } } @@ -57,6 +79,10 @@ export default class EmbeddedBff { return this.mount.name ?? 'The embedded BFF'; } + private log(message: string, context?: Record): string { + return formatLog(this.mount.gateway ? '[Gateway API]' : '[BFF]', message, context); + } + /** * Load the package and validate the options the caller handed to `addBff()`. Called before the * agent mounts anything: everything checked here is caller input, and `parseConfig` throws on a @@ -71,7 +97,7 @@ export default class EmbeddedBff { this.assertRequiredVersion(bffPackage.version); this.claimsBffPath = bffPackage.claimsBffPath; - this.config = parseConfig({ + const env: NodeJS.ProcessEnv = { FOREST_AUTH_SECRET: this.options.authSecret, FOREST_ENV_SECRET: this.options.envSecret, FOREST_SERVER_URL: this.options.forestServerUrl, @@ -86,7 +112,9 @@ export default class EmbeddedBff { // Off unless asked for: the document is not filtered per caller, so mounting a BFF must not // publish the name of every exposed collection and field on an already-open port. BFF_OPENAPI_ENABLED: String(embedOptions.openapiEnabled ?? false), - }); + }; + + this.config = parseConfig(env, this.mount.gateway ? GATEWAY_API_CONFIG_LABELS : undefined); } private assertRequiredVersion(installed: string): void { @@ -124,6 +152,8 @@ export default class EmbeddedBff { dispatcher, basePath: this.mount.prefix, allowedOAuthClients: this.mount.allowedOAuthClients, + gatewayVersion: AGENT_VERSION, + ...(this.mount.gateway ? { labels: GATEWAY_API_CONFIG_LABELS, name: 'Gateway API' } : {}), // Counters are the schema cache's and the action-endpoint resolver's only channel — they take // no logger — and every one of them reports a failure, so they go to the host's logs. Gauges // do not: they are periodic cache sizes, and the default console sink reports them at Info, @@ -132,10 +162,10 @@ export default class EmbeddedBff { // Tags carried through: `action_endpoint_error` and `action_endpoint_miss` name the // rendering, collection and action that failed, which is the whole of what makes the line // actionable — the metric name alone says only that something, somewhere, did not resolve. - increment: (name, tags) => this.options.logger('Warn', formatLog(`metric ${name}`, tags)), + increment: (name, tags) => this.options.logger('Warn', this.log(`metric ${name}`, tags)), gauge: () => undefined, }, - logger: (level, message, context) => this.options.logger(level, formatLog(message, context)), + logger: (level, message, context) => this.options.logger(level, this.log(message, context)), }); // stop() may have landed while buildBff() was in flight. Assigning anyway would resurrect a @@ -144,7 +174,10 @@ export default class EmbeddedBff { this.bff = bff; - this.options.logger('Info', formatLog(`Embedded BFF mounted on ${this.mount.prefix}`)); + this.options.logger( + 'Info', + this.log(`${this.mount.name ?? 'Embedded BFF'} mounted on ${this.mount.prefix}`), + ); if (!this.mount.skipIpWhitelistWarning) await this.warnIfExemptFromIpWhitelist(); } @@ -171,7 +204,7 @@ export default class EmbeddedBff { this.options.logger( 'Warn', - formatLog( + this.log( `The IP whitelist is enabled for this environment, but requests served under ` + `${this.mount.prefix} are not subject to it: they reach the agent in-process, which the ` + `whitelist exempts as a trusted loopback caller. A resolved API key or a valid OAuth ` + @@ -181,7 +214,7 @@ export default class EmbeddedBff { } catch (error) { this.options.logger( 'Debug', - formatLog('Could not read the IP whitelist configuration', { + this.log('Could not read the IP whitelist configuration', { cause: error instanceof Error ? error.message : String(error), }), ); @@ -220,10 +253,15 @@ export default class EmbeddedBff { this.options.logger( 'Warn', - formatLog('Stopped the embedded BFF with activity logs still in flight', { - timeoutMs, - unfinished, - }), + this.log( + `Stopped ${ + this.mount.gateway ? 'the Gateway API' : 'the embedded BFF' + } with activity logs still in flight`, + { + timeoutMs, + unfinished, + }, + ), ); } @@ -239,8 +277,8 @@ export default class EmbeddedBff { if (!this.bff) { const type = this.stopped ? 'bff_stopped' : 'bff_not_started'; const message = this.stopped - ? 'The embedded BFF was stopped with the agent.' - : 'The embedded BFF is not started yet.'; + ? `${this.name} was stopped with the agent.` + : `${this.name} is not started yet.`; res.statusCode = 503; res.setHeader('Content-Type', 'application/json'); diff --git a/packages/agent/src/gateway.ts b/packages/agent/src/gateway.ts index e0bb7679c3..b2ac194f56 100644 --- a/packages/agent/src/gateway.ts +++ b/packages/agent/src/gateway.ts @@ -1,12 +1,15 @@ import type { BffEmbedOptions, GatewayOptions, McpEmbedOptions, RootHandler } from './types'; // eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-dynamic-require, global-require -const { peerDependencies } = require('../package.json') as { +const { peerDependencies, version } = require('../package.json') as { peerDependencies: Record; + version: string; }; export const AGENT_BFF_PEER_VERSION = peerDependencies['@forestadmin/agent-bff']; +export const AGENT_VERSION = version; + const isWithin = (path: string, root: string) => path === root || path.startsWith(`${root}/`); const HTTP_SERVICE_UNAVAILABLE = 503; diff --git a/packages/agent/test/agent-bff-lifecycle.test.ts b/packages/agent/test/agent-bff-lifecycle.test.ts index afe3313889..5c769d48fb 100644 --- a/packages/agent/test/agent-bff-lifecycle.test.ts +++ b/packages/agent/test/agent-bff-lifecycle.test.ts @@ -20,11 +20,17 @@ const mockParseConfig = jest.fn(); const mockInvalidate = jest.fn(); const mockBffCallback = jest.fn(); +const PEER_VERSION: string = + jest.requireActual('../package.json').peerDependencies['@forestadmin/agent-bff']; + jest.mock('@forestadmin/agent-bff', () => ({ __esModule: true, IN_PROCESS_AGENT_URL: 'http://in-process.agent', parseConfig: (env: unknown) => mockParseConfig(env), buildBff: (options: unknown) => mockBuildBff(options), + get version() { + return PEER_VERSION; + }, })); const mockExecutorStart = jest.fn(); @@ -95,6 +101,16 @@ describe('the embedded BFF lifecycle', () => { expect(mockBuildBff).toHaveBeenCalledTimes(1); }); + + it('should log the addBff() mount line byte-identically', async () => { + const logger = jest.fn(); + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true, logger }), + ).addBff(); + await agent.start(); + + expect(logger).toHaveBeenCalledWith('Info', '[BFF] Embedded BFF mounted on /bff'); + }); }); describe('when addBff is called once startup has begun', () => { @@ -265,6 +281,28 @@ describe('the embedded BFF lifecycle', () => { '{"timeoutMs":10000,"unfinished":["\'action\' request on \'books\'"]}', ); }); + + it('should name the Gateway API under addGateway, not the embedded BFF', async () => { + const logger = jest.fn(); + const drainActivityLogs = jest.fn(async () => ["'action' request on 'books'"]); + mockBuildBff.mockResolvedValue({ + callback: mockBffCallback, + invalidate: mockInvalidate, + drainActivityLogs, + }); + const agent = new Agent( + factories.forestAdminHttpDriverOptions.build({ skipSchemaUpdate: true, logger }), + ).addGateway({ api: {} }); + await agent.start(); + + await agent.stop(); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + '[Gateway API] Stopped the Gateway API with activity logs still in flight ' + + '{"timeoutMs":10000,"unfinished":["\'action\' request on \'books\'"]}', + ); + }); }); describe('when stop() lands while the BFF is still being built', () => { diff --git a/packages/agent/test/agent-gateway-wiring.test.ts b/packages/agent/test/agent-gateway-wiring.test.ts index 2b1b48879b..ed5bcb6bd1 100644 --- a/packages/agent/test/agent-gateway-wiring.test.ts +++ b/packages/agent/test/agent-gateway-wiring.test.ts @@ -511,7 +511,7 @@ describe('addGateway() checks at start()', () => { await agent.start(); - expect(logger).toHaveBeenCalledWith('Info', '[BFF] Embedded BFF mounted on /ai/api'); + expect(logger).toHaveBeenCalledWith('Info', '[Gateway API] The Gateway API mounted on /ai/api'); expect(mockBuildBff).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/ai/api' })); await agent.stop(); }); diff --git a/packages/agent/test/bff/embedded-bff-config-labels.test.ts b/packages/agent/test/bff/embedded-bff-config-labels.test.ts new file mode 100644 index 0000000000..8db199c342 --- /dev/null +++ b/packages/agent/test/bff/embedded-bff-config-labels.test.ts @@ -0,0 +1,148 @@ +import type { AgentDispatcher, Bff, BuildBffOptions } from '@forestadmin/agent-bff'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import EmbeddedBff, { GATEWAY_API_CONFIG_LABELS } from '../../src/embedded-bff'; +import * as factories from '../__factories__'; + +const mockBuildBff = jest.fn(); + +jest.mock('@forestadmin/agent-bff', () => { + const actual = jest.requireActual('@forestadmin/agent-bff'); + + return { + __esModule: true, + IN_PROCESS_AGENT_URL: actual.IN_PROCESS_AGENT_URL, + parseConfig: actual.parseConfig, + claimsBffPath: actual.claimsBffPath, + buildBff: (options: BuildBffOptions) => mockBuildBff(options), + get version() { + return actual.version; + }, + }; +}); + +const AGENT_VERSION: string = jest.requireActual('../../package.json').version; + +const INVALID_KEY = 'not-base64!!'; + +const INVALID_KEY_ERROR = 'must be base64-encoded and exactly 32 bytes (AES-256).'; + +function buildEmbedded(gateway: boolean, tokenEncryptionKey?: string): EmbeddedBff { + return new EmbeddedBff( + factories.forestAdminHttpDriverOptions.build(), + { tokenEncryptionKey }, + gateway ? { prefix: '/api', name: 'The Gateway API', gateway: true } : { prefix: '/bff' }, + ); +} + +function answer503(embedded: EmbeddedBff): { type: string; status: number; message: string } { + const res = { + statusCode: 0, + body: '', + headers: {} as Record, + setHeader(name: string, value: string) { + this.headers[name] = value; + }, + end(chunk?: string) { + this.body = chunk ?? ''; + }, + }; + + embedded.handle({} as IncomingMessage, res as unknown as ServerResponse); + + return JSON.parse(res.body).error; +} + +describe('EmbeddedBff config labels', () => { + beforeEach(() => { + mockBuildBff.mockReset(); + }); + + describe('under addGateway({ api })', () => { + it('should name api.tokenEncryptionKey, not BFF_TOKEN_ENCRYPTION_KEY, on an invalid key', async () => { + const embedded = buildEmbedded(true, INVALID_KEY); + + await expect(embedded.prepare()).rejects.toThrow( + `Invalid configuration: api.tokenEncryptionKey ${INVALID_KEY_ERROR}`, + ); + }); + + it('should pass the gateway labels, the Gateway API name and the agent version to buildBff', async () => { + const embedded = buildEmbedded(true); + const bff = { callback: jest.fn(), invalidate: jest.fn() } as unknown as Bff; + + mockBuildBff.mockResolvedValueOnce(bff); + + await embedded.start(jest.fn() as unknown as AgentDispatcher); + + expect(mockBuildBff).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayVersion: AGENT_VERSION, + labels: GATEWAY_API_CONFIG_LABELS, + name: 'Gateway API', + }), + ); + }); + + it('should answer the addGateway() 503s naming the Gateway API', async () => { + const embedded = buildEmbedded(true); + + expect(answer503(embedded)).toEqual({ + type: 'bff_not_started', + status: 503, + message: 'The Gateway API is not started yet.', + }); + + await embedded.stop(); + + expect(answer503(embedded)).toEqual({ + type: 'bff_stopped', + status: 503, + message: 'The Gateway API was stopped with the agent.', + }); + }); + }); + + describe('under addBff()', () => { + it('should keep the BFF_TOKEN_ENCRYPTION_KEY message unchanged', async () => { + const embedded = buildEmbedded(false, INVALID_KEY); + + await expect(embedded.prepare()).rejects.toThrow( + `Invalid configuration: BFF_TOKEN_ENCRYPTION_KEY ${INVALID_KEY_ERROR}`, + ); + }); + + it('should answer the addBff() 503s naming the embedded BFF', async () => { + const embedded = buildEmbedded(false); + + expect(answer503(embedded)).toEqual({ + type: 'bff_not_started', + status: 503, + message: 'The embedded BFF is not started yet.', + }); + + await embedded.stop(); + + expect(answer503(embedded)).toEqual({ + type: 'bff_stopped', + status: 503, + message: 'The embedded BFF was stopped with the agent.', + }); + }); + + it('should pass the agent version but no labels and no name to buildBff', async () => { + const embedded = buildEmbedded(false); + const bff = { callback: jest.fn(), invalidate: jest.fn() } as unknown as Bff; + + mockBuildBff.mockResolvedValueOnce(bff); + + await embedded.start(jest.fn() as unknown as AgentDispatcher); + + const options = mockBuildBff.mock.calls[0][0]; + + expect(options.gatewayVersion).toBe(AGENT_VERSION); + expect('labels' in options).toBe(false); + expect('name' in options).toBe(false); + }); + }); +}); diff --git a/packages/agent/test/bff/embedded-bff.e2e.test.ts b/packages/agent/test/bff/embedded-bff.e2e.test.ts index 59eb81cd99..0aced38c76 100644 --- a/packages/agent/test/bff/embedded-bff.e2e.test.ts +++ b/packages/agent/test/bff/embedded-bff.e2e.test.ts @@ -2,7 +2,7 @@ import type { BffEmbedOptions } from '../../src/types'; import type { Server } from 'http'; import type supertest from 'supertest'; -import { buildBff, parseConfig } from '@forestadmin/agent-bff'; +import { version as bffVersion, buildBff, parseConfig } from '@forestadmin/agent-bff'; import express from 'express'; import jsonwebtoken from 'jsonwebtoken'; import { tmpdir } from 'os'; @@ -14,6 +14,8 @@ import SearchDataSource from './fixtures/search-datasource'; import Agent from '../../src/agent'; import MockForestServer from '../__helper__/mock-forest-server'; +const agentVersion: string = jest.requireActual('../../package.json').version; + const AUTH_SECRET = 'test-auth-secret-32-chars-min!!!'; const ENV_SECRET = '0'.repeat(64); const BOOT_TIMEOUT_MS = 30_000; @@ -352,6 +354,24 @@ describe('embedded BFF', () => { }); }); + describe('response headers on a /bff route', () => { + it('should send both version headers and expose them with the deprecation ones to browsers', async () => { + const response = await request(app) + .get('/bff/agent/v1/context') + .set('Authorization', `Bearer ${sessionToken()}`) + .set('Origin', ALLOWED_ORIGIN); + + expect(response.status).toBe(200); + expect(response.headers['x-forest-bff-version']).toBe(bffVersion); + expect(response.headers['x-forest-gateway-version']).toBe(agentVersion); + expect(response.headers['access-control-expose-headers']).toBe( + 'Deprecation, Link, Retry-After, X-Forest-Gateway-Version, X-Forest-Bff-Version', + ); + expect(response.headers.deprecation).toBe('@1791158400'); + expect(response.headers.link).toContain('rel="deprecation"'); + }); + }); + describe('over the in-process transport', () => { itServesTheDataContract(() => (url, body) => authenticated(`/bff${url}`, body)); }); From b21b38a5a786fb0d633fab3ff0c38f3b8f463380 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 12 Oct 2026 14:25:14 +0200 Subject: [PATCH 05/11] feat(gateway): serve the MCP and the API on one port (#1978) * feat(gateway): serve the MCP and the API on one port Co-Authored-By: Claude Opus 5.5 * ci: lint and test the gateway package Co-Authored-By: Claude Opus 5.5 * fix(gateway): build against the workspace agent-bff and mcp-server Exact pins fall behind the workspace on each release, since a private package is skipped by multi-semantic-release, and Yarn then pulls the published versions instead of linking the local ones. Co-Authored-By: Claude Opus 5.5 * feat(mcp-server): export the listener variables parseMcpEnv reads Co-Authored-By: Claude Opus 5.5 * fix(gateway): follow the listener variable names agent-bff and mcp-server own A rename on their side now fails the gateway's typecheck, or carries over through the exported name, instead of silently moving the API's public url and port. Co-Authored-By: Claude Opus 5.5 * feat(agent-bff): export the openapi --output handling for other CLIs Co-Authored-By: Claude Opus 5.5 * refactor(gateway): reuse agent-bff's openapi --output handling Co-Authored-By: Claude Opus 5.5 * feat(gateway): read one set of env vars, legacy names as aliases (#1980) * feat(gateway): warn that forest-mcp-server and forest-bff are deprecated (#1990) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 27 + .github/workflows/docker-publish-gateway.yml | 461 +++ packages/agent-bff/README.md | 6 + packages/agent-bff/src/cli-dispatch.ts | 61 +- packages/agent-bff/src/cli-output.ts | 48 + packages/agent-bff/src/index.ts | 4 +- .../agent-bff/src/openapi/render-openapi.ts | 14 +- packages/agent-bff/test/index.test.ts | 5 + .../test/openapi/openapi-cli.test.ts | 56 +- .../normalize-domain-list.ts | 11 +- .../normalize-domain-list.test.ts | 18 + packages/gateway/Dockerfile | 117 + packages/gateway/LICENSE | 674 +++++ packages/gateway/docker/README.md | 63 + .../gateway/docker/build-deps-manifest.js | 131 + packages/gateway/docker/check-deps-closure.js | 96 + packages/gateway/docker/deps/yarn.lock | 2695 +++++++++++++++++ packages/gateway/docker/scan-gate.js | 58 + packages/gateway/docker/smoke-test.sh | 244 ++ packages/gateway/jest.config.ts | 7 + packages/gateway/package.json | 41 + packages/gateway/src/cli-dispatch.ts | 103 + packages/gateway/src/cli.ts | 14 + packages/gateway/src/env-aliases.ts | 59 + packages/gateway/src/gateway-env.ts | 281 ++ packages/gateway/src/healthcheck.ts | 52 + packages/gateway/src/index.ts | 12 + packages/gateway/src/run-gateway.ts | 186 ++ packages/gateway/src/standalone-handler.ts | 87 + packages/gateway/src/version.ts | 4 + packages/gateway/test/cli-dispatch.test.ts | 108 + packages/gateway/test/env-aliases.test.ts | 115 + packages/gateway/test/gateway-env.test.ts | 544 ++++ packages/gateway/test/healthcheck.test.ts | 111 + .../test/helpers/fake-forest-server.ts | 43 + packages/gateway/test/helpers/gateway-env.ts | 38 + packages/gateway/test/run-gateway.test.ts | 589 ++++ packages/gateway/test/shutdown.test.ts | 190 ++ packages/gateway/tsconfig.eslint.json | 3 + packages/gateway/tsconfig.json | 7 + packages/mcp-server/README.md | 5 + packages/mcp-server/package.json | 2 +- packages/mcp-server/src/cli.ts | 7 + packages/mcp-server/src/index.ts | 2 +- packages/mcp-server/src/mcp-env.ts | 13 +- packages/mcp-server/test/cli.test.ts | 14 + packages/mcp-server/test/index.test.ts | 10 + 47 files changed, 7369 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/docker-publish-gateway.yml create mode 100644 packages/agent-bff/src/cli-output.ts create mode 100644 packages/gateway/Dockerfile create mode 100644 packages/gateway/LICENSE create mode 100644 packages/gateway/docker/README.md create mode 100644 packages/gateway/docker/build-deps-manifest.js create mode 100644 packages/gateway/docker/check-deps-closure.js create mode 100644 packages/gateway/docker/deps/yarn.lock create mode 100644 packages/gateway/docker/scan-gate.js create mode 100644 packages/gateway/docker/smoke-test.sh create mode 100644 packages/gateway/jest.config.ts create mode 100644 packages/gateway/package.json create mode 100644 packages/gateway/src/cli-dispatch.ts create mode 100644 packages/gateway/src/cli.ts create mode 100644 packages/gateway/src/env-aliases.ts create mode 100644 packages/gateway/src/gateway-env.ts create mode 100644 packages/gateway/src/healthcheck.ts create mode 100644 packages/gateway/src/index.ts create mode 100644 packages/gateway/src/run-gateway.ts create mode 100644 packages/gateway/src/standalone-handler.ts create mode 100644 packages/gateway/src/version.ts create mode 100644 packages/gateway/test/cli-dispatch.test.ts create mode 100644 packages/gateway/test/env-aliases.test.ts create mode 100644 packages/gateway/test/gateway-env.test.ts create mode 100644 packages/gateway/test/healthcheck.test.ts create mode 100644 packages/gateway/test/helpers/fake-forest-server.ts create mode 100644 packages/gateway/test/helpers/gateway-env.ts create mode 100644 packages/gateway/test/run-gateway.test.ts create mode 100644 packages/gateway/test/shutdown.test.ts create mode 100644 packages/gateway/tsconfig.eslint.json create mode 100644 packages/gateway/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aac4b5fefc..7d3bf6a6b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,6 +79,7 @@ jobs: - datasource-sql - datasource-toolkit - forestadmin-client + - gateway - plugin-aws-s3 - plugin-export-advanced - plugin-flattener @@ -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: @@ -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 diff --git a/.github/workflows/docker-publish-gateway.yml b/.github/workflows/docker-publish-gateway.yml new file mode 100644 index 0000000000..0e5ce37a13 --- /dev/null +++ b/.github/workflows/docker-publish-gateway.yml @@ -0,0 +1,461 @@ +name: Publish gateway Docker image + +on: + # Releases publish via workflow_dispatch from build.yml's `release` job, not a + # tag-push trigger — the release commit's `[skip ci]` would suppress it. + pull_request: + # Validate on any change to what the image actually contains. The 6 dependency + # packages ship as `dist` inside it, so their `src` is image code and belongs + # here — a change there can break startup with nothing in this repo to catch it. + # Their manifests matter for a second reason: a dep bump in one of them drifts + # the image's dedicated lockfile (see packages/gateway/docker/). + paths: + - 'packages/gateway/**' + - 'packages/mcp-server/**' + - 'packages/agent-bff/src/**' + - 'packages/agent-bff/package.json' + - 'packages/agent-client/src/**' + - 'packages/agent-client/package.json' + - 'packages/agent-toolkit/src/**' + - 'packages/agent-toolkit/package.json' + - 'packages/datasource-toolkit/src/**' + - 'packages/datasource-toolkit/package.json' + - 'packages/forestadmin-client/src/**' + - 'packages/forestadmin-client/package.json' + # The builder runs `yarn install --frozen-lockfile` from the repo root, so a + # lockfile-only change can alter the build (build.yml installs without + # --frozen-lockfile and wouldn't catch an inconsistency). + - 'yarn.lock' + - '.github/workflows/docker-publish-gateway.yml' + workflow_dispatch: + inputs: + version: + description: 'Version tag to publish (e.g. 1.0.0)' + required: true + +# Every `uses:` below is pinned to a commit, with its release in the trailing comment. A tag is a +# pointer the action's owner can move — tj-actions/changed-files was rewritten across all of its +# tags in March 2025 — and the jobs here hold `packages: write` and a registry login, so a moved +# tag would run under credentials that can publish to GHCR. A commit cannot be moved. +# +# The cost is that a pin does not receive the action's fixes by itself: bump these deliberately, the +# way the Dockerfile's base digest is bumped. + +# The floor, for every job. `packages: write` is granted per job, to the two that push, and never +# to `validate`: that one runs on pull_request, so it builds and executes code the PR controls — +# its Dockerfile, its docker/*.js, its smoke test. A token that can write to GHCR has no business +# being reachable from there, and validate needs none: it builds with `push: false` and never logs +# in to the registry. +permissions: + contents: read + +concurrency: + # A publish is keyed by the version alone, deliberately without the ref: two dispatches + # for the same version from different branches would otherwise run side by side, both + # find the version tag free, and both publish — the immutability check in the merge job + # only holds if same-version publishes are serialised. They queue instead of cancelling, + # so the second one runs that check against what the first actually published. + # + # Different versions still publish concurrently and can finish out of order; the merge + # job re-decides latest-stable against the tags as they are then, so the finishing order + # cannot regress the mutable tags. + # + # PRs keep the opposite behaviour, grouped by ref and cancelling: a superseded validation + # of an older push is waste, not a release. + group: docker-publish-gateway-${{ github.event_name == 'workflow_dispatch' && format('publish-{0}', github.event.inputs.version) || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # On PRs: build the image (no push) to catch Dockerfile breakage before release. + validate: + name: Validate Dockerfile build + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: ".nvmrc" + + - name: Check @forestadmin dependency closure + run: node packages/gateway/docker/check-deps-closure.js + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - name: Build (no push) + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: packages/gateway/Dockerfile + platforms: linux/amd64 + push: false + load: true + tags: gateway:pr + build-args: APT_EPOCH=${{ github.run_id }}-${{ github.run_attempt }} + cache-from: type=gha,scope=gateway-amd64 + + # The build only proves the image compiles. Run it to catch entrypoint + # breakage, a missing module (e.g. an un-copied @forestadmin package), a + # missing Redoc bundle, or a startup crash — none of which a build-only step + # would surface. + - name: Smoke test (CLI + module graph + boot) + run: sh packages/gateway/docker/smoke-test.sh gateway:pr + + # Gate by ORIGIN, not severity. OS packages can only be fixed here, so they + # BLOCK; the Gateway's npm deps are already shipped via the package, so blocking + # the image would just desync GHCR from npm — those are report-only (fixed at + # the source). + # + # The blocking scan keeps `ignore-unfixed`, because a finding with no fixed + # version is not actionable here: the apt upgrade above already pulls every + # patch Debian has published. On the current base that is the whole of the + # HIGH/CRITICAL OS surface — 0 fixed, 56 unfixed — so blocking on unfixed + # would block every build, forever, in exchange for an ignore file that grows + # to 56 entries. The report below keeps them visible instead. + - name: Scan OS packages (blocking) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: gateway:pr + vuln-type: os + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '1' + - name: Scan OS packages, unfixed included (report) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: gateway:pr + vuln-type: os + severity: CRITICAL,HIGH + ignore-unfixed: false + exit-code: '0' + - name: Scan libraries (report) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: gateway:pr + vuln-type: library + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '0' + format: json + output: trivy-libs.json + - name: Report npm deps + run: node packages/gateway/docker/scan-gate.js trivy-libs.json + + extract-version: + name: Extract version + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + outputs: + full: ${{ steps.version.outputs.full }} + minor: ${{ steps.version.outputs.minor }} + major: ${{ steps.version.outputs.major }} + # No checkout: splitting a version string needs no repository. Whether this is + # the latest stable version is decided once, in the merge job, against the tags + # as they are at that moment (see there). + steps: + - name: Parse version from input + id: version + # The dispatch input reaches the shell through `env`, never interpolated into + # the script: `${{ }}` is substituted as raw text before bash sees it, so a + # crafted version would run as commands with this job's packages:write token. + # It is then validated as a semver before anything is done with it — the + # `ref:` the build job checks out is built from the same value. + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + VERSION="$INPUT_VERSION" + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::Invalid version input: expected MAJOR.MINOR.PATCH[-prerelease]." + exit 1 + fi + echo "full=$VERSION" >> $GITHUB_OUTPUT + echo "minor=${VERSION%.*}" >> $GITHUB_OUTPUT + echo "major=${VERSION%%.*}" >> $GITHUB_OUTPUT + + build: + name: Build (${{ matrix.arch }}) + if: github.event_name != 'pull_request' + needs: extract-version + runs-on: ${{ matrix.runner }} + # Pushes by digest, so it is one of the two jobs that needs the registry. + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Build from the requested version's tag, not the default branch. Taken from + # extract-version rather than the raw input, so only a validated semver ever + # reaches a ref. + ref: ${{ format('@forestadmin/gateway@{0}', needs.extract-version.outputs.full) }} + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: ".nvmrc" + + # Also run here, not only in the PR job: a dependency change can reach main + # through a path the `paths:` filter above does not cover, and would then hit + # the release build unchecked. The failure mode is a container that dies on + # `Cannot find module`, which no other step in this job would catch. + - name: Check @forestadmin dependency closure + run: node packages/gateway/docker/check-deps-closure.js + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build and LOAD locally first (no push) so we can smoke-test and scan the + # exact image before it reaches the registry. Nothing is published until + # both gates pass — otherwise a vulnerable/broken image would be pullable + # by digest even when the gate "fails". + - name: Build (load for gating) + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: packages/gateway/Dockerfile + platforms: ${{ matrix.platform }} + load: true + tags: gateway:${{ matrix.arch }} + build-args: APT_EPOCH=${{ github.run_id }}-${{ github.run_attempt }} + cache-from: type=gha,scope=gateway-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=gateway-${{ matrix.arch }} + + - name: Smoke test (CLI + module graph + boot) + run: sh packages/gateway/docker/smoke-test.sh gateway:${{ matrix.arch }} + + # Gate by origin (see the validate job): OS packages block; the Gateway's npm + # deps are report-only, and unfixed OS findings are reported, not blocked. + - name: Scan OS packages (blocking) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: gateway:${{ matrix.arch }} + vuln-type: os + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '1' + - name: Scan OS packages, unfixed included (report) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: gateway:${{ matrix.arch }} + vuln-type: os + severity: CRITICAL,HIGH + ignore-unfixed: false + exit-code: '0' + - name: Scan libraries (report) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: gateway:${{ matrix.arch }} + vuln-type: library + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '0' + format: json + output: trivy-libs.json + - name: Report npm deps + run: node packages/gateway/docker/scan-gate.js trivy-libs.json + + # All gates passed (smoke + OS scan). Publish by digest. + # + # This is a SECOND build, not a push of the image the steps above scanned: the + # docker driver cannot load a push-by-digest result, and push-by-digest is what + # the multi-arch manifest below is assembled from. It is the same context and + # the same Dockerfile, and the gating build wrote every layer to the cache this + # one reads, so buildx reuses them — but that is a cache hit, not a checked + # identity. What the gate really buys is that a build which fails smoke or scan + # never reaches the registry at all. + # + # The first push CREATES the GHCR package, private. Nothing here can change that: + # visibility is a package setting, and `packages: write` on GITHUB_TOKEN grants the + # push, not the admin. Flip it to public once, by hand, in the package settings, + # right after the first release: until then an anonymous pull is refused. + - name: Push by digest + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: packages/gateway/Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=ghcr.io/forestadmin/gateway,push-by-digest=true,name-canonical=true,push=true + sbom: true + build-args: APT_EPOCH=${{ github.run_id }}-${{ github.run_attempt }} + cache-from: type=gha,scope=gateway-${{ matrix.arch }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - name: Upload digest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: digest-gateway-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Publish multi-arch manifest + if: github.event_name != 'pull_request' + needs: [extract-version, build] + runs-on: ubuntu-latest + # Assembles and pushes the multi-arch manifest. + permissions: + contents: read + packages: write + outputs: + # Decided here rather than in extract-version, against the tags as they stand + # when the manifest is written. notify-server reads it to gate the dispatch. + is_latest: ${{ steps.publish.outputs.is_latest }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Tags only — this job builds nothing, it re-reads the release history below. + fetch-depth: 0 + + - name: Download digests + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: digest-gateway-* + merge-multiple: true + path: /tmp/digests + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push multi-arch manifest + id: publish + working-directory: /tmp/digests + env: + IMAGE: ghcr.io/forestadmin/gateway + FULL: ${{ needs.extract-version.outputs.full }} + MINOR: ${{ needs.extract-version.outputs.minor }} + MAJOR: ${{ needs.extract-version.outputs.major }} + run: | + # :$FULL is advertised as immutable, so a re-dispatch of a version that + # already shipped must not repoint it at the images this run just built. + # Compare what the tag resolves to against those digests — the attestation + # manifests the SBOM adds carry no architecture, so they are not part of the + # comparison. Identical means this is a replay (recovering a merge job that + # died, or moving the mutable tags): let it through, imagetools rewrites the + # same list. Different means the version was rebuilt, and the answer is a new + # version, not a silently different 1.0.0. + if EXISTING=$(docker buildx imagetools inspect "$IMAGE:$FULL" --raw 2>/tmp/inspect.err); then + HAVE=$(printf '%s' "$EXISTING" \ + | jq -r '.manifests[]? | select(.platform.architecture != "unknown") | .digest' | sort) + # With `sbom: true` each pushed digest is an index (image + attestation), which + # imagetools flattens into :$FULL. Compare platform manifests on both sides. + WANT=$( + for digest in *; do + RAW=$(docker buildx imagetools inspect "$IMAGE@sha256:$digest" --raw) + if printf '%s' "$RAW" | jq -e '.manifests' >/dev/null; then + printf '%s' "$RAW" \ + | jq -r '.manifests[] | select(.platform.architecture != "unknown") | .digest' + else + echo "sha256:$digest" + fi + done | sort + ) + if [ "$HAVE" != "$WANT" ]; then + echo "::error::$IMAGE:$FULL already exists and points to other images." + echo "published: $(echo $HAVE)" >&2 + echo "this run: $(echo $WANT)" >&2 + exit 1 + fi + echo "::notice::$IMAGE:$FULL already points to these images; re-tagging is a no-op." + # Only an explicit not-found means the tag is free. Every other failure — a network + # blip, an expired token, a registry 5xx — resolves nothing, and reading it as "absent" + # would hand this run a tag it never checked. buildx says `: not found` for a tag + # that is not there, and `failed to authorize: ...` for a repository it cannot read. + elif grep -qiE 'not found|manifest unknown|name unknown' /tmp/inspect.err; then + echo "::notice::$IMAGE:$FULL does not exist yet." + else + cat /tmp/inspect.err >&2 + echo "::error::Could not resolve $IMAGE:$FULL. Refusing to publish over a tag whose contents are unknown." + exit 1 + fi + + # The mutable tags (:latest, :major, :minor) move only for the highest STABLE + # version of this package: a prerelease never matches the pattern, nor does a + # rebuild of an older version. Decided against tags fetched right before the + # push, not at the start of the run: dispatches for different versions run + # concurrently, and a stale reading would let an older one move :latest back. + # Serializing merges across versions is not an option: GitHub keeps one pending + # job per concurrency group and cancels the others, which would drop their + # immutable :$FULL tag. + git -C "$GITHUB_WORKSPACE" fetch --tags --force --quiet origin + PREFIX="@forestadmin/gateway@" + LATEST_STABLE=$(git -C "$GITHUB_WORKSPACE" tag --list "${PREFIX}*" \ + | sed "s|^${PREFIX}||" \ + | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V | tail -n1) + IS_LATEST=false + if [ "$FULL" = "$LATEST_STABLE" ]; then + IS_LATEST=true + else + echo "::notice::${FULL} is not the latest stable version (${LATEST_STABLE}); leaving the mutable tags alone." + fi + echo "is_latest=$IS_LATEST" >> "$GITHUB_OUTPUT" + + TAGS="-t $IMAGE:$FULL" + if [ "$IS_LATEST" = "true" ]; then + TAGS="$TAGS -t $IMAGE:$MINOR -t $IMAGE:$MAJOR -t $IMAGE:latest" + fi + docker buildx imagetools create $TAGS \ + $(printf "$IMAGE@sha256:%s " *) + + - name: Inspect manifest + run: docker buildx imagetools inspect ghcr.io/forestadmin/gateway:${{ needs.extract-version.outputs.full }} + + # Notify forestadmin-server that a new Gateway image is out. It acts on it only once its + # receiver for this event exists; until then the event is dropped and nothing deploys. + # Only for the latest STABLE version — never a prerelease, and never a rebuild of + # an older version, neither of which may regress production. That decision is the + # merge job's: it is made against the release tags as they stand when the manifest + # is written, which is the same reading that decided whether :latest moved. + # GITHUB_TOKEN can't dispatch into another repo, so this uses a dedicated token + # (fine-grained PAT or GitHub App) with Contents:write on forestadmin-server. + notify-server: + name: Notify forestadmin-server + if: github.event_name != 'pull_request' && needs.merge.outputs.is_latest == 'true' + needs: [extract-version, merge] + runs-on: ubuntu-latest + steps: + - name: Send repository_dispatch + env: + DISPATCH_TOKEN: ${{ secrets.SERVER_DEPLOY_DISPATCH_TOKEN }} + VERSION: ${{ needs.extract-version.outputs.full }} + run: | + if [ -z "$DISPATCH_TOKEN" ]; then + echo "::error::SERVER_DEPLOY_DISPATCH_TOKEN is not set — cannot notify forestadmin-server." + exit 1 + fi + curl -fsSL -X POST \ + -H "Authorization: Bearer ${DISPATCH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/ForestAdmin/forestadmin-server/dispatches \ + -d "{\"event_type\":\"gateway-published\",\"client_payload\":{\"version\":\"${VERSION}\"}}" + echo "Dispatched gateway-published (version ${VERSION}) to forestadmin-server." diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index df0ce6a38c..82e64c41e7 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -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. diff --git a/packages/agent-bff/src/cli-dispatch.ts b/packages/agent-bff/src/cli-dispatch.ts index ebeee77a02..25263c146c 100644 --- a/packages/agent-bff/src/cli-dispatch.ts +++ b/packages/agent-bff/src/cli-dispatch.ts @@ -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] @@ -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']); @@ -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, @@ -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)) { @@ -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); diff --git a/packages/agent-bff/src/cli-output.ts b/packages/agent-bff/src/cli-output.ts new file mode 100644 index 0000000000..c16aeadf0f --- /dev/null +++ b/packages/agent-bff/src/cli-output.ts @@ -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 }; +} diff --git a/packages/agent-bff/src/index.ts b/packages/agent-bff/src/index.ts index bc3bcb16de..42fe7d2b81 100644 --- a/packages/agent-bff/src/index.ts +++ b/packages/agent-bff/src/index.ts @@ -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, @@ -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, diff --git a/packages/agent-bff/src/openapi/render-openapi.ts b/packages/agent-bff/src/openapi/render-openapi.ts index b77c60db37..85e51d8210 100644 --- a/packages/agent-bff/src/openapi/render-openapi.ts +++ b/packages/agent-bff/src/openapi/render-openapi.ts @@ -1,3 +1,4 @@ +import type { ConfigLabels } from '../config/env-config'; import type { Logger } from '../ports/logger-port'; import { generateOpenApiDocument, serializeOpenApi } from './openapi-document'; @@ -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); @@ -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 { + const labels = options.labels ?? {}; const basePath = normalizeBasePath(options.basePath); const authSecret = env.FOREST_AUTH_SECRET; @@ -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}`); diff --git a/packages/agent-bff/test/index.test.ts b/packages/agent-bff/test/index.test.ts index ee4d780f1c..f1a232bf7e 100644 --- a/packages/agent-bff/test/index.test.ts +++ b/packages/agent-bff/test/index.test.ts @@ -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(); }); }); }); diff --git a/packages/agent-bff/test/openapi/openapi-cli.test.ts b/packages/agent-bff/test/openapi/openapi-cli.test.ts index 3d316cb317..9db2782194 100644 --- a/packages/agent-bff/test/openapi/openapi-cli.test.ts +++ b/packages/agent-bff/test/openapi/openapi-cli.test.ts @@ -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'; @@ -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', () => { @@ -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([ diff --git a/packages/forestadmin-client/src/oauth-client-allowlist/normalize-domain-list.ts b/packages/forestadmin-client/src/oauth-client-allowlist/normalize-domain-list.ts index 42ef65d787..c48e0855ca 100644 --- a/packages/forestadmin-client/src/oauth-client-allowlist/normalize-domain-list.ts +++ b/packages/forestadmin-client/src/oauth-client-allowlist/normalize-domain-list.ts @@ -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.', ); } @@ -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.', ); } @@ -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.`); } }); } diff --git a/packages/forestadmin-client/test/oauth-client-allowlist/normalize-domain-list.test.ts b/packages/forestadmin-client/test/oauth-client-allowlist/normalize-domain-list.test.ts index 6be37f80a5..9cdbd41688 100644 --- a/packages/forestadmin-client/test/oauth-client-allowlist/normalize-domain-list.test.ts +++ b/packages/forestadmin-client/test/oauth-client-allowlist/normalize-domain-list.test.ts @@ -53,4 +53,22 @@ describe('normalizeDomainList', () => { expect(() => normalizeDomainList(['dust.tt#x'])).toThrow(/bare domains/); expect(() => normalizeDomainList(['dust.tt\\unexpected'])).toThrow(/bare domains/); }); + + it('should name the given label on an empty list', () => { + expect(() => normalizeDomainList([], 'FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS')).toThrow( + 'Invalid FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS: no domains to allow.', + ); + }); + + it('should name the given label on an invalid entry', () => { + expect(() => + normalizeDomainList(['https://dust.tt'], 'FOREST_MCP_ALLOWED_OAUTH_CLIENTS'), + ).toThrow('Invalid FOREST_MCP_ALLOWED_OAUTH_CLIENTS entry "https://dust.tt"'); + }); + + it('should name allowedOAuthClients by default', () => { + expect(() => normalizeDomainList(['https://dust.tt'])).toThrow( + 'Invalid allowedOAuthClients entry "https://dust.tt"', + ); + }); }); diff --git a/packages/gateway/Dockerfile b/packages/gateway/Dockerfile new file mode 100644 index 0000000000..32e42727e8 --- /dev/null +++ b/packages/gateway/Dockerfile @@ -0,0 +1,117 @@ +# syntax=docker/dockerfile:1 +# +# Production image for @forestadmin/gateway: the MCP, the API, or both, on one port. +# The build context MUST be the monorepo root (yarn workspaces + yarn.lock): +# +# docker build -f packages/gateway/Dockerfile -t ghcr.io/forestadmin/gateway:latest . +# +# Configuration is passed entirely via environment variables; FOREST_GATEWAY_SERVICES +# (mcp, api or mcp,api) is required. + +# Base image pinned by digest (node:22-bookworm-slim). Bump via Renovate/Dependabot. +FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 AS base + +# ── Stage 1: build ──────────────────────────────────────────────────────────── +FROM base AS builder +WORKDIR /app + +# Same install as the agent-bff image: manifests + lockfile first so a source-only +# change keeps the install layer cached, --frozen-lockfile so the tree matches +# yarn.lock, --ignore-scripts to skip husky and native builds never loaded here. +COPY --parents package.json yarn.lock packages/*/package.json packages/*/*/package.json ./ +RUN yarn install --frozen-lockfile --ignore-scripts + +COPY . . + +# Build the Gateway and only its 6 workspace dependencies, in topological order. +# agent-bff's `build` also copies the Redoc bundle into dist/docs. +RUN node_modules/.bin/lerna run build \ + --scope @forestadmin/gateway \ + --include-dependencies + +# ── Stage 2: isolated prod deps ─────────────────────────────────────────────── +# Only the external runtime deps of the 7 workspace packages, from the committed +# lockfile (see docker/README.md): a dependency change it does not cover fails here. +FROM base AS prod-deps +WORKDIR /deps + +COPY packages/gateway/docker/build-deps-manifest.js ./build-deps-manifest.js +COPY packages/gateway/docker/deps/yarn.lock ./yarn.lock +COPY packages/gateway/package.json ./packages/gateway/package.json +COPY packages/agent-bff/package.json ./packages/agent-bff/package.json +COPY packages/agent-client/package.json ./packages/agent-client/package.json +COPY packages/agent-toolkit/package.json ./packages/agent-toolkit/package.json +COPY packages/datasource-toolkit/package.json ./packages/datasource-toolkit/package.json +COPY packages/forestadmin-client/package.json ./packages/forestadmin-client/package.json +COPY packages/mcp-server/package.json ./packages/mcp-server/package.json + +RUN node build-deps-manifest.js packages package.json +RUN yarn install --frozen-lockfile --ignore-scripts + +# ── Stage 3: runtime ────────────────────────────────────────────────────────── +FROM base AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# The digest pin freezes Debian packages too, so security fixes can only land here. APT_EPOCH is +# part of this layer's cache key: CI passes the run id so every run refetches; local builds reuse +# the cached layer until --no-cache. +ARG APT_EPOCH +RUN echo "$APT_EPOCH" \ + && apt-get update -o APT::Update::Error-Mode=any \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --with-new-pkgs -o Dpkg::Options::=--force-confold \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=prod-deps /deps/node_modules ./node_modules + +# @forestadmin/* workspace packages placed directly in node_modules, resolved by +# walking up to /app/node_modules/@forestadmin// and its package.json "main". +COPY --from=builder /app/packages/agent-bff/dist ./node_modules/@forestadmin/agent-bff/dist +COPY --from=builder /app/packages/agent-bff/package.json ./node_modules/@forestadmin/agent-bff/package.json +COPY --from=builder /app/packages/agent-client/dist ./node_modules/@forestadmin/agent-client/dist +COPY --from=builder /app/packages/agent-client/package.json ./node_modules/@forestadmin/agent-client/package.json +COPY --from=builder /app/packages/agent-toolkit/dist ./node_modules/@forestadmin/agent-toolkit/dist +COPY --from=builder /app/packages/agent-toolkit/package.json ./node_modules/@forestadmin/agent-toolkit/package.json +COPY --from=builder /app/packages/datasource-toolkit/dist ./node_modules/@forestadmin/datasource-toolkit/dist +COPY --from=builder /app/packages/datasource-toolkit/package.json ./node_modules/@forestadmin/datasource-toolkit/package.json +COPY --from=builder /app/packages/forestadmin-client/dist ./node_modules/@forestadmin/forestadmin-client/dist +COPY --from=builder /app/packages/forestadmin-client/package.json ./node_modules/@forestadmin/forestadmin-client/package.json +COPY --from=builder /app/packages/mcp-server/dist ./node_modules/@forestadmin/mcp-server/dist +COPY --from=builder /app/packages/mcp-server/package.json ./node_modules/@forestadmin/mcp-server/package.json + +# Gateway entry point. +COPY --from=builder /app/packages/gateway/dist ./packages/gateway/dist +COPY --from=builder /app/packages/gateway/package.json ./packages/gateway/package.json + +# The working directory, and only it: `openapi --output` defaults to ./openapi.json. Not +# recursive — node_modules and dist stay root-owned, so the process cannot rewrite its code. +RUN chown node:node /app + +USER node + +# Not `forestadmin-agent-bff`, the default of the preload this image borrows from agent-bff. +# Setting OTEL_SERVICE_NAME at run time still wins. +ENV OTEL_SERVICE_NAME=forest-gateway + +# 3931 when the mcp service is on, 3450 with api only; PORT overrides both. A real port only: +# with PORT=0 nothing outside the process learns the port, and the healthcheck fails. +EXPOSE 3450 3931 + +# The probe resolves the port the way the Gateway does (PORT, then its legacy aliases, then the +# per-service default), so the two never disagree. Only a 200 passes: a degraded service makes +# /health answer 503, and a misconfigured container is meant to read unhealthy. +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["node", "/app/packages/gateway/dist/healthcheck.js"] + +# OCI image metadata (https://github.com/opencontainers/image-spec). +LABEL org.opencontainers.image.title="forestadmin-gateway" \ + org.opencontainers.image.description="Forest Admin MCP and API services on one port." \ + org.opencontainers.image.source="https://github.com/ForestAdmin/agent-nodejs" \ + org.opencontainers.image.licenses="GPL-3.0" + +# Exec form, no wrapper script: the CLI takes positional subcommands (`openapi`). +# +# The preload is agent-bff's, required from the same path the Gateway imports agent-bff by, so +# the tracing handle it parks is the one the shutdown path flushes. Inert until an OTLP endpoint +# or OTEL_TRACES_EXPORTER is set. +ENTRYPOINT ["node", "--require", "/app/node_modules/@forestadmin/agent-bff/dist/tracing-preload.js", "/app/packages/gateway/dist/cli.js"] diff --git a/packages/gateway/LICENSE b/packages/gateway/LICENSE new file mode 100644 index 0000000000..e62ec04cde --- /dev/null +++ b/packages/gateway/LICENSE @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/packages/gateway/docker/README.md b/packages/gateway/docker/README.md new file mode 100644 index 0000000000..e23f3aca5d --- /dev/null +++ b/packages/gateway/docker/README.md @@ -0,0 +1,63 @@ +# Docker image build assets + +The production image installs its runtime dependencies into an **isolated** +`node_modules` (rather than shipping the whole monorepo's hoisted tree). This +keeps the image small with a fully pinned dependency tree. + +## How it works + +- [`build-deps-manifest.js`](./build-deps-manifest.js) merges the external + (non-`@forestadmin`) runtime dependencies of the Gateway and its 6 + workspace dependencies, plus the OpenTelemetry packages, into a single `package.json`. + Three OTel packages are named there; `sdk-node` drags in every OTLP exporter + and the Zipkin one behind them, which is what lets the image honour + `OTEL_TRACES_EXPORTER` and `OTEL_EXPORTER_OTLP_PROTOCOL` without the app ever + choosing an exporter. +- [`deps/yarn.lock`](./deps/) pins every transitive version. +- The Docker build regenerates the manifest from the live `package.json` files + and runs `yarn install --frozen-lockfile`. If a workspace dependency changes + in a way the committed lock does not cover, **the build fails** instead of + silently shipping an unpinned version. +- [`check-deps-closure.js`](./check-deps-closure.js) recomputes the real + transitive `@forestadmin` closure of the Gateway and fails when the hardcoded list + or the Dockerfile `COPY` lines drift from it. +- [`smoke-test.sh`](./smoke-test.sh) runs a locally-loaded image before it is + published: CLI surface, module graph, Redoc bundle, boot with `mcp,api` and + an unreachable Forest server, the Docker `HEALTHCHECK` per service set, and a + clean stop on SIGTERM. + +## Running the smoke test + +```bash +sh packages/gateway/docker/smoke-test.sh +``` + +It needs `docker`, `curl`, `openssl` and **`python3`** on the host. The last one +serves a two-line stub of the Forest server so the last boot can be fully +configured and reach `/health` 200 — the path the image's own HEALTHCHECK +demands, which nothing else exercises. All four are present on the GitHub +runners; on a slim box python3 is the one likely to be missing. + +## Updating the lockfile + +Only `deps/yarn.lock` is committed — the manifest is generated on demand (the +Docker build regenerates it too), so there is no stale `package.json` to drift. +Run this whenever a runtime dependency of one of the 7 workspace packages +changes **or when the `OTEL_DEPENDENCIES` versions in `build-deps-manifest.js` +are bumped** (the build will fail with `--frozen-lockfile` until you do): + +The generated manifest carries the repo's pinned `packageManager` +(`yarn@1.22.19`), so with Corepack enabled (`corepack enable`) the refresh uses +the same Yarn as the Docker build — a global Yarn 4 would otherwise emit an +incompatible lockfile format. + +```bash +# from the monorepo root (Corepack enabled) +TMP=$(mktemp -d) +node packages/gateway/docker/build-deps-manifest.js packages "$TMP/package.json" +( cd "$TMP" && yarn install --ignore-scripts ) +cp "$TMP/yarn.lock" packages/gateway/docker/deps/yarn.lock +rm -rf "$TMP" +``` + +Then commit the updated `deps/yarn.lock`. diff --git a/packages/gateway/docker/build-deps-manifest.js b/packages/gateway/docker/build-deps-manifest.js new file mode 100644 index 0000000000..753188f091 --- /dev/null +++ b/packages/gateway/docker/build-deps-manifest.js @@ -0,0 +1,131 @@ +// Generates the package.json for the Docker image's isolated runtime deps. +// +// It merges the external (non-@forestadmin) runtime dependencies of the Gateway +// and its 6 workspace dependencies into a single manifest, plus the OpenTelemetry +// packages used for APM (Docker-only; not shipped to npm consumers of the CLI). +// +// The output is deterministic (sorted keys). A committed yarn.lock sits next to +// the generated manifest; the Docker build regenerates the manifest and runs +// `yarn install --frozen-lockfile`, so any workspace dependency change that the +// lock does not cover fails the build instead of silently drifting. +// +// Usage: node build-deps-manifest.js +// directory containing the workspace packages (e.g. "packages") +// path to write the merged package.json + +const fs = require('fs'); +const path = require('path'); + +const WORKSPACE_PACKAGES = [ + 'gateway', + 'agent-bff', + 'agent-client', + 'agent-toolkit', + 'datasource-toolkit', + 'forestadmin-client', + 'mcp-server', +]; + +// Pinned to exact versions — OTel ships only in the Docker image, so nothing else bumps them. +// sdk-node drags in every OTLP exporter plus the Zipkin one, which is why the image can honour +// OTEL_TRACES_EXPORTER and OTEL_EXPORTER_OTLP_PROTOCOL without naming an exporter here. +// A fixable CRITICAL/HIGH in this tree blocks every publish of this image until someone raises a +// pin here and refreshes deps/yarn.lock (see the propagator-jaeger resolution below for the shape +// of that fix). Renovate does not watch this file — it is a plain object, not a manifest — so an +// unrelated release will stall on it unless someone is looking. +const OTEL_DEPENDENCIES = { + '@opentelemetry/sdk-node': '0.219.0', + '@opentelemetry/auto-instrumentations-node': '0.77.0', + '@opentelemetry/exporter-trace-otlp-http': '0.219.0', +}; + +// Security pins for transitive deps whose parents never ship a patched range. +// They mirror the monorepo root's `resolutions` for the packages that actually +// appear in this closure — the isolated install does not inherit the root ones. +const RESOLUTIONS = { + // qs DoS + array-limit bypass (Dependabot #488-#489, patched in 6.16.0). + '**/qs': '^6.16.0', + // jsonapi-serializer pins lodash ^4.17.x. + '**/lodash': '^4.18.0', + // auto-instrumentations-node pins propagator-jaeger 2.8.0 (CVE-2026-59892, fixed in 2.9.0). + '**/@opentelemetry/propagator-jaeger': '2.9.0', + // Dependabot alert #452 — Hono ReDoS + memo() SSR retention + lang middleware DoS (patched in 4.12.34). + '**/@modelcontextprotocol/sdk/hono': '^4.12.34', + // fast-uri host confusion + SSRF cluster (Dependabot #499-#502, patched in 3.1.6). + '**/ajv/fast-uri': '^3.1.6', + // Mirrors the root resolutions: express 5 (mcp-server, MCP SDK) pins an unpatched body-parser. + '**/express/body-parser': '^2.3.0', +}; + +function generate(packagesDir, outFile) { + // A flat install holds one version per dependency, so two packages asking for + // different ranges is a decision, not something to let iteration order settle. + // It has never happened here — refusing keeps it that way, and keeps the answer + // out of this script. + const deps = {}; + const declaredBy = {}; + + for (const pkg of WORKSPACE_PACKAGES) { + const manifestPath = path.join(packagesDir, pkg, 'package.json'); + const { dependencies = {} } = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + for (const [name, range] of Object.entries(dependencies)) { + if (name.startsWith('@forestadmin/')) continue; + + if (deps[name] !== undefined && deps[name] !== range) { + throw new Error( + `Conflicting ranges for "${name}": ${deps[name]} (${declaredBy[name]}) and ` + + `${range} (${pkg}). Align the version in the source packages.`, + ); + } + + deps[name] = range; + declaredBy[name] = pkg; + } + } + + Object.assign(deps, OTEL_DEPENDENCIES); + + const sorted = Object.fromEntries(Object.keys(deps).sort().map(key => [key, deps[key]])); + + const manifest = { name: 'gateway-docker-deps', private: true }; + + // Carry the monorepo's pinned package manager so a manual lockfile refresh + // (yarn install on this generated manifest) uses the same Yarn via Corepack, + // not a contributor's global Yarn 4 which would emit an incompatible lockfile. + // Resolved from the repo root relative to this script; absent in the Docker + // build (root package.json isn't copied there) — harmless, the image uses its + // own bundled Yarn 1.x. + const packageManager = rootPackageManager(); + if (packageManager) manifest.packageManager = packageManager; + + manifest.dependencies = sorted; + manifest.resolutions = RESOLUTIONS; + + fs.writeFileSync(outFile, `${JSON.stringify(manifest, null, 2)}\n`); +} + +function rootPackageManager() { + try { + const root = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8'), + ); + + return root.packageManager; + } catch { + return undefined; + } +} + +if (require.main === module) { + const [, , packagesDir, outFile] = process.argv; + + if (!packagesDir || !outFile) { + console.error('Usage: node build-deps-manifest.js '); + process.exit(1); + } + + generate(packagesDir, outFile); +} + +module.exports = { WORKSPACE_PACKAGES, generate }; diff --git a/packages/gateway/docker/check-deps-closure.js b/packages/gateway/docker/check-deps-closure.js new file mode 100644 index 0000000000..94811b0208 --- /dev/null +++ b/packages/gateway/docker/check-deps-closure.js @@ -0,0 +1,96 @@ +// Guard-rail against silent drift in the Docker image's @forestadmin closure. +// +// The image's runtime deps are assembled from a HARDCODED set in two places: +// - WORKSPACE_PACKAGES in build-deps-manifest.js (gathers their external deps) +// - the `COPY --from=builder .../dist` lines in the Dockerfile (ships their build) +// +// If the Gateway gains/loses an @forestadmin/* dependency, both must change or the +// runtime image breaks at startup (Cannot find module) — invisible to a build-only CI. +// This check recomputes the real transitive @forestadmin closure of the Gateway +// and fails if the hardcoded list or the Dockerfile COPYs don't match it. +// +// Usage: node check-deps-closure.js (exits non-zero on drift) + +const fs = require('fs'); +const path = require('path'); +const { WORKSPACE_PACKAGES } = require('./build-deps-manifest'); + +const PACKAGES_DIR = path.resolve(__dirname, '../..'); +const DOCKERFILE = path.resolve(__dirname, '../Dockerfile'); +const ROOT_PACKAGE = '@forestadmin/gateway'; + +// Map every @forestadmin/* package name to its directory under packages/. +const nameToDir = {}; +for (const dir of fs.readdirSync(PACKAGES_DIR)) { + const manifestPath = path.join(PACKAGES_DIR, dir, 'package.json'); + if (!fs.existsSync(manifestPath)) continue; + const { name } = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (name) nameToDir[name] = dir; +} + +// BFS the transitive @forestadmin closure (including the root itself). +const closure = new Set(); +const queue = [ROOT_PACKAGE]; +while (queue.length) { + const name = queue.shift(); + const dir = nameToDir[name]; + + // An @forestadmin package that lives outside this monorepo would fall through every + // mechanism the image has: the walk cannot reach its dependencies, build-deps-manifest + // skips the whole @forestadmin/ prefix when gathering external deps, and there is no + // dist to COPY. It would be absent from the image entirely, so refuse rather than + // report a clean closure. + if (!dir) { + console.error(`Unknown @forestadmin dependency "${name}": no package under packages/ declares it.`); + console.error('The Docker image can only ship workspace packages — vendor it or add it to the monorepo.'); + process.exit(1); + } + + if (closure.has(dir)) continue; + closure.add(dir); + const { dependencies = {} } = JSON.parse( + fs.readFileSync(path.join(PACKAGES_DIR, dir, 'package.json'), 'utf8'), + ); + for (const dep of Object.keys(dependencies)) { + if (dep.startsWith('@forestadmin/')) queue.push(dep); + } +} + +const actual = [...closure].sort(); +const declared = [...WORKSPACE_PACKAGES].sort(); +const dockerfile = fs.readFileSync(DOCKERFILE, 'utf8'); + +const errors = []; + +const missingFromList = actual.filter(p => !declared.includes(p)); +const extraInList = declared.filter(p => !actual.includes(p)); +if (missingFromList.length) errors.push(`WORKSPACE_PACKAGES is missing: ${missingFromList.join(', ')}`); +if (extraInList.length) errors.push(`WORKSPACE_PACKAGES has stale entries: ${extraInList.join(', ')}`); + +// Only active COPY instructions count — a path that survives in a comment or in prose +// would otherwise pass the check while shipping nothing. +const copied = dockerfile + .split('\n') + .filter(line => /^\s*COPY\s/.test(line)) + .join('\n'); + +// Every closure package is copied out of the builder, and BOTH halves are required: +// without the dist there is no code, and without the package.json there is no "main" +// for Node to resolve the package by. The dependencies land in node_modules and the Gateway +// in packages/gateway/, but they are all copied FROM the same builder paths. +for (const pkg of actual) { + for (const file of ['dist', 'package.json']) { + if (!copied.includes(`/app/packages/${pkg}/${file}`)) { + errors.push(`Dockerfile is missing a COPY for packages/${pkg}/${file}`); + } + } +} + +if (errors.length) { + console.error('@forestadmin dependency closure drift detected:\n - ' + errors.join('\n - ')); + console.error(`\nActual closure: ${actual.join(', ')}`); + console.error('Update WORKSPACE_PACKAGES (build-deps-manifest.js) and the Dockerfile COPY lines to match.'); + process.exit(1); +} + +console.log(`@forestadmin closure OK (${actual.length} packages): ${actual.join(', ')}`); diff --git a/packages/gateway/docker/deps/yarn.lock b/packages/gateway/docker/deps/yarn.lock new file mode 100644 index 0000000000..90011ae55a --- /dev/null +++ b/packages/gateway/docker/deps/yarn.lock @@ -0,0 +1,2695 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@asteasolutions/zod-to-openapi@8.5.0": + version "8.5.0" + resolved "https://registry.yarnpkg.com/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz#f073822daad87c4ab2ae991ee86b1a5070ac9942" + integrity sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q== + dependencies: + openapi3-ts "^4.1.2" + +"@grpc/grpc-js@^1.14.3": + version "1.14.5" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.5.tgz#12f97bd9f716358e377ffaa768195d1219eb76a3" + integrity sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.1.tgz#5a6b290ccbfb1ae2f6775afb74e9898bd8c5d4e8" + integrity sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.5" + yargs "^17.7.2" + +"@hapi/bourne@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" + integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== + +"@hono/node-server@^1.19.9 || ^2.0.5": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-2.1.4.tgz#12160a6a8b7a947a9371f48ca53bf2e24be5119f" + integrity sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + +"@koa/bodyparser@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@koa/bodyparser/-/bodyparser-6.1.0.tgz#8e001da5eedac39ef68ebcefc57f706e9a29423e" + integrity sha512-thVG/Utbz9+dB4Nl8EBJKoaOI1DzO74dJqeDFILbAVhUq6C+4rmmrKFwxiDE63ScywOs0CHypx1IUrSRMueFTw== + dependencies: + "@types/co-body" "^6.1.3" + co-body "^6.2.0" + lodash.merge "^4.6.2" + type-is "^2.0.1" + +"@modelcontextprotocol/sdk@^1.28.0": + version "1.32.1" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz#853286b3240b6d8143f5f8e6d58af85c77d475ab" + integrity sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw== + dependencies: + "@hono/node-server" "^1.19.9 || ^2.0.5" + ajv "^8.17.1" + ajv-formats "^3.0.1" + content-type "^1.0.5" + cors "^2.8.5" + cross-spawn "^7.0.5" + eventsource "^3.0.2" + eventsource-parser "^3.0.0" + express "^5.2.1" + express-rate-limit "^8.2.1" + hono "^4.11.4" + jose "^6.1.3" + json-schema-typed "^8.0.2" + pkce-challenge "^5.0.0" + raw-body "^3.0.0" + zod "^3.25 || ^4.0" + zod-to-json-schema "^3.25.1" + +"@noble/hashes@^1.1.5": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@opentelemetry/api-logs@0.219.0", "@opentelemetry/api-logs@^0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz#3303f10f43e6ff1741f8f6019e43a92723781630" + integrity sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg== + dependencies: + "@opentelemetry/api" "^1.3.0" + +"@opentelemetry/api@^1.3.0": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" + integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== + +"@opentelemetry/auto-instrumentations-node@0.77.0": + version "0.77.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.77.0.tgz#cc5df74ff122b9c506a1ebf78aed95ee9a4aff71" + integrity sha512-LkF930Cs+v+ZO/qV6LolbocvFkJJ812BBDyRNjQpwllBA+rFvGtP/voXPuh24QV3JKl5/3c3GulLHvMn8spqkQ== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/instrumentation-amqplib" "^0.66.0" + "@opentelemetry/instrumentation-aws-lambda" "^0.71.0" + "@opentelemetry/instrumentation-aws-sdk" "^0.74.0" + "@opentelemetry/instrumentation-bunyan" "^0.64.0" + "@opentelemetry/instrumentation-cassandra-driver" "^0.64.0" + "@opentelemetry/instrumentation-connect" "^0.62.0" + "@opentelemetry/instrumentation-cucumber" "^0.35.0" + "@opentelemetry/instrumentation-dataloader" "^0.36.0" + "@opentelemetry/instrumentation-dns" "^0.62.0" + "@opentelemetry/instrumentation-express" "^0.67.0" + "@opentelemetry/instrumentation-fs" "^0.38.0" + "@opentelemetry/instrumentation-generic-pool" "^0.62.0" + "@opentelemetry/instrumentation-graphql" "^0.67.0" + "@opentelemetry/instrumentation-grpc" "^0.219.0" + "@opentelemetry/instrumentation-hapi" "^0.65.0" + "@opentelemetry/instrumentation-host-metrics" "^0.2.0" + "@opentelemetry/instrumentation-http" "^0.219.0" + "@opentelemetry/instrumentation-ioredis" "^0.67.0" + "@opentelemetry/instrumentation-kafkajs" "^0.28.0" + "@opentelemetry/instrumentation-knex" "^0.63.0" + "@opentelemetry/instrumentation-koa" "^0.67.0" + "@opentelemetry/instrumentation-lru-memoizer" "^0.63.0" + "@opentelemetry/instrumentation-memcached" "^0.62.0" + "@opentelemetry/instrumentation-mongodb" "^0.72.0" + "@opentelemetry/instrumentation-mongoose" "^0.65.0" + "@opentelemetry/instrumentation-mysql" "^0.65.0" + "@opentelemetry/instrumentation-mysql2" "^0.65.0" + "@opentelemetry/instrumentation-nestjs-core" "^0.65.0" + "@opentelemetry/instrumentation-net" "^0.63.0" + "@opentelemetry/instrumentation-openai" "^0.17.0" + "@opentelemetry/instrumentation-oracledb" "^0.44.0" + "@opentelemetry/instrumentation-pg" "^0.71.0" + "@opentelemetry/instrumentation-pino" "^0.65.0" + "@opentelemetry/instrumentation-redis" "^0.67.0" + "@opentelemetry/instrumentation-restify" "^0.64.0" + "@opentelemetry/instrumentation-router" "^0.63.0" + "@opentelemetry/instrumentation-runtime-node" "^0.32.0" + "@opentelemetry/instrumentation-socket.io" "^0.66.0" + "@opentelemetry/instrumentation-tedious" "^0.38.0" + "@opentelemetry/instrumentation-undici" "^0.29.0" + "@opentelemetry/instrumentation-winston" "^0.63.0" + "@opentelemetry/resource-detector-alibaba-cloud" "^0.34.0" + "@opentelemetry/resource-detector-aws" "^2.19.0" + "@opentelemetry/resource-detector-azure" "^0.27.0" + "@opentelemetry/resource-detector-container" "^0.8.10" + "@opentelemetry/resource-detector-gcp" "^0.54.0" + "@opentelemetry/resources" "^2.0.0" + "@opentelemetry/sdk-node" "^0.219.0" + +"@opentelemetry/configuration@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/configuration/-/configuration-0.219.0.tgz#cb950aaa77bdb921a3fd636dc792f8beb712be17" + integrity sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q== + dependencies: + "@opentelemetry/core" "2.8.0" + yaml "^2.0.0" + +"@opentelemetry/context-async-hooks@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz#24381ef388bc28d53fa8dad72dfd1429acc82016" + integrity sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw== + +"@opentelemetry/core@2.12.0", "@opentelemetry/core@^2.0.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.12.0.tgz#b44e9806111f066a2df578e37fc88ef46ccdfe20" + integrity sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/core@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.8.0.tgz#f6e86de3688bdb54a6ca8f4935363a5b588ae91c" + integrity sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/core@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.9.0.tgz#49de86106f86255b471b2ff035ef1003a851b252" + integrity sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/exporter-logs-otlp-grpc@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.219.0.tgz#d828e7495d05c54fd51a6495094eaf9e485805c1" + integrity sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ== + dependencies: + "@grpc/grpc-js" "^1.14.3" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/sdk-logs" "0.219.0" + +"@opentelemetry/exporter-logs-otlp-http@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.219.0.tgz#3a4764ec408efdf9c573cfc3e4ffc41392694d3b" + integrity sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A== + dependencies: + "@opentelemetry/api-logs" "0.219.0" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/sdk-logs" "0.219.0" + +"@opentelemetry/exporter-logs-otlp-proto@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.219.0.tgz#de0431c17a944928471ffab1dcea9e02e70271ac" + integrity sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg== + dependencies: + "@opentelemetry/api-logs" "0.219.0" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-logs" "0.219.0" + "@opentelemetry/sdk-trace-base" "2.8.0" + +"@opentelemetry/exporter-metrics-otlp-grpc@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.219.0.tgz#b9d1ede26f45adfbcbf087dd7d4065dfc1268121" + integrity sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ== + dependencies: + "@grpc/grpc-js" "^1.14.3" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/exporter-metrics-otlp-http" "0.219.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-metrics" "2.8.0" + +"@opentelemetry/exporter-metrics-otlp-http@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.219.0.tgz#aed2238678486434cff020cbdfef07b24f1370b6" + integrity sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-metrics" "2.8.0" + +"@opentelemetry/exporter-metrics-otlp-proto@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.219.0.tgz#7a7a20dba913f00c115fb48901502680c41e23dc" + integrity sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/exporter-metrics-otlp-http" "0.219.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-metrics" "2.8.0" + +"@opentelemetry/exporter-prometheus@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.219.0.tgz#8ea911269156f3cd4f08a1e8e5727df3f316b687" + integrity sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-metrics" "2.8.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/exporter-trace-otlp-grpc@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.219.0.tgz#ef421636a3e360b4e8e1d26a2cae0d6f49c5187a" + integrity sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q== + dependencies: + "@grpc/grpc-js" "^1.14.3" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-trace-base" "2.8.0" + +"@opentelemetry/exporter-trace-otlp-http@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz#37ad13bd871fe5d983754bb2ad2e2ac2f3e167ec" + integrity sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-trace-base" "2.8.0" + +"@opentelemetry/exporter-trace-otlp-proto@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.219.0.tgz#cec3c1da46bb3ac5774461830634d6509b797bf1" + integrity sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-trace-base" "2.8.0" + +"@opentelemetry/exporter-zipkin@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.8.0.tgz#03f5ca641f611fc15b9530d2a2ec6238a167bda1" + integrity sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-trace-base" "2.8.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/instrumentation-amqplib@^0.66.0": + version "0.66.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.66.0.tgz#c29676ad8740d982edcef5353726cf30cefc786d" + integrity sha512-lyJgobzP0Ce+tRGOkdnrb60apfqU89xB9FeMTmo1TJU007KTMLiLFd4iCfTiBEXzBsVplx7kwrVN4FqGBUcD2Q== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + +"@opentelemetry/instrumentation-aws-lambda@^0.71.0": + version "0.71.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.71.0.tgz#7d9082a15e2a01aa3b8666b6fefe27c0b4aef4b6" + integrity sha512-9Sv6flQDeNNF6ZbiLgn+NYJa220yRZdDSIdgDZsqNubpDnYAPF33OHcQDlE8mFiaOq14mngoPS48JnYc8JT+Ug== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/propagator-aws-xray" "^2.1.4" + "@opentelemetry/semantic-conventions" "^1.27.0" + "@types/aws-lambda" "^8.10.155" + +"@opentelemetry/instrumentation-aws-sdk@^0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.74.0.tgz#8a05691ef0eeede0cc0271b3f350233fd18cd731" + integrity sha512-EMLUGgx2wJSXdwMEFdwd3IaW+mkUF8PENdzDFQ1FRdztzMG1d1XN76ORIjAMsXcKQISlRRcz93AWPQeBPn4EKA== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.34.0" + +"@opentelemetry/instrumentation-bunyan@^0.64.0": + version "0.64.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.64.0.tgz#c133310306aa6d5eed712b6d59c89f2d4b257430" + integrity sha512-jrRNFvpREutmpoWhk1T8n9q/RYdxbViXwSUPHN8yQR1bzgtwfOl/y8G/p8Xfudlky9GGsqw5WRc6q6QrfgF3pw== + dependencies: + "@opentelemetry/api-logs" "^0.219.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@types/bunyan" "1.8.11" + +"@opentelemetry/instrumentation-cassandra-driver@^0.64.0": + version "0.64.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.64.0.tgz#8861b78fbd359e2cea8b5eb47367a33e7a2d0526" + integrity sha512-KN+iOsmPI0nkX2lfgNgHBrHNaDuxDwIbwFrlvyrZ4bAT8bTKcCOXhne70O9qihM8+T1F4tjvPXpCfhKZbmsYiw== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.37.0" + +"@opentelemetry/instrumentation-connect@^0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.62.0.tgz#db1d0dd394f020cd3b903761ccd838ab36571142" + integrity sha512-ZGV2sOyeffqMiqoh4RpsPTs/TUI5cCS+cEWvC9wUfvaEekR5omR6P/ClG+QDwasGBlKx2zfFPjSYPpzUo81XAw== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + "@types/connect" "3.4.38" + +"@opentelemetry/instrumentation-cucumber@^0.35.0": + version "0.35.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.35.0.tgz#6037bf6e6d0eef058d4f5ec233f3913dfea6b20b" + integrity sha512-H9NsbcFiVOFOcOu40+VOOjxdTeonu0VHI3mte5ie5ka/bazzIPGncQoHpL6su53C3/sgMdKjE6ZuwsB7Y8gQpA== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + +"@opentelemetry/instrumentation-dataloader@^0.36.0": + version "0.36.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.36.0.tgz#64c2316acd7e4a35bdd24d69fc11ebb3d26f8451" + integrity sha512-gE0mTk+EnVaBN0mPRM1V6FqzQ9VckTp6ZFIssU5hxy+e3sqspYILBhV/0IHZ33qxGa3B9buLVZnuzVjUISfyoQ== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-dns@^0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.62.0.tgz#514d8fc16f91884353493a2e627e121a8fde5096" + integrity sha512-6v0X8wEqhIyv2b7MXhmipyCitJfm0vnF5mLBTWXojovoHp7P1EpN5sb12LgdhVlozhGwRZdzb6OvKUVsxKMD8g== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-express@^0.67.0": + version "0.67.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.67.0.tgz#0a63c4900db27b7203b7b7d01a61a6d88b97eeea" + integrity sha512-1WTWX2YNZIV+jPmEqdf/Vd1gHMT92TKA/0pf/iIItWhV6+RhzhnUUW4kSWQn8L3qVcgWEzQ860/ZOwaIwayi8A== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + +"@opentelemetry/instrumentation-fs@^0.38.0": + version "0.38.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.38.0.tgz#b4a951dadb085efd1a04dbc100c9974e280a33f4" + integrity sha512-6OBofWODg0RcPkl3bA+7yPf0e4Vi3O7ZxlFGY5QHPMMLxWVMnjWPEXQ2NlLBk19Sr8LcvEyyZOStdLJrT5o2dQ== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-generic-pool@^0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.62.0.tgz#7cedf3c9c2431c829856e92fa1ebc0212a1d3282" + integrity sha512-IhO2y/MaK1oZ4EbUgdkSVT+XViPq86IAz4lwPe9jaba00M2yDWs8+f9xjcH3tK5IC3dZuAxXmifGmfvuJp92jw== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-graphql@^0.67.0": + version "0.67.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.67.0.tgz#e01e077c254b42317a053898d245ce9a3509596d" + integrity sha512-NMUmuhtYvv3AwkK4zsHQbTCXS81QS63hbNZRKfXc7W8f4KbWeKLmqKqvnfjUcqZoGS0eAw2kNKNYcbtbQ7baAg== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-grpc@^0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.219.0.tgz#dcb8a4ef9ba26b9c8e8eee19a5546b34a6e2e852" + integrity sha512-GyW1Kfbf7uiJXeBZovB/uXPUdkaZYWzB2ZPCdY2CU7+6V207u8wlCM+zVd3UwhEjOBrMbZAGq+m38aBEI/EVtA== + dependencies: + "@opentelemetry/instrumentation" "0.219.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/instrumentation-hapi@^0.65.0": + version "0.65.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.65.0.tgz#3cdeb5c1424c5929589ba10cf846ed4996ff5b31" + integrity sha512-Whhas9iU0SfK/7XBcgCwfW5c9AaxjxtZpzW21t3Ml8XZ6Irc3hF36JDolMmFa7nUjML9n9bSUAZjwCgrK+2UdQ== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + +"@opentelemetry/instrumentation-host-metrics@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-host-metrics/-/instrumentation-host-metrics-0.2.0.tgz#20cea70fb5620a41b0b30fedc3d52751801f9b3c" + integrity sha512-NIttCEOLdg1ebbDiJpCf0Ly1OGIa10isesik+K2dnXy2P99q4muUFjpaLtTnhkENrt9SmR0Zrxzq7B+W/VNWyw== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + systeminformation "^5.31.6" + +"@opentelemetry/instrumentation-http@^0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.219.0.tgz#8dac2a15e9b9247fb8514a5525ecc0b86f2d67bb" + integrity sha512-nNt1fqpyah/OKjNHdEOu8xLwISppRU2qJuF8aR+fCcftVwdFkPgtworBLA+TI1HU2iF508jcQBF2gerWczJAXg== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/instrumentation" "0.219.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + forwarded-parse "2.1.2" + +"@opentelemetry/instrumentation-ioredis@^0.67.0": + version "0.67.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.67.0.tgz#a87a1f0389b1c997b00842ca9255d61ed814c9b0" + integrity sha512-dv64vQ4aXbJvRMMAFrMUSzDeJrNv/uQMLjfaav4LHAOar7Xn08W3pkoYoYEnzq/n2+fGgG96rp9S0gQ+VnLDiw== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/redis-common" "^0.38.3" + "@opentelemetry/semantic-conventions" "^1.33.0" + +"@opentelemetry/instrumentation-kafkajs@^0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.28.0.tgz#fbbfa3a1942e74f05945a581f44a5362de1e1fba" + integrity sha512-dztkg70nJds3Uc0Xo3NFlRqL5iYgGYWh8myuuGfRC6NnXJchY0Kw9QnBjTZxBSldXU+P6nv2snDVMmlxuy6fEw== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.30.0" + +"@opentelemetry/instrumentation-knex@^0.63.0": + version "0.63.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.63.0.tgz#9dc7e96097bc55f1a59f59bb0eebf8a15635bb7b" + integrity sha512-XrpRahI/9vTrfSUfkhy8jGX8KMRKecQIPU9GyEZ8gkR030iJwQYsMmKGO5TK9R80cQGUopXwDvV55zmVNEkcPg== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.1" + +"@opentelemetry/instrumentation-koa@^0.67.0": + version "0.67.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.67.0.tgz#ca60e192f2deb5b22173654fbe36ef8704d9f862" + integrity sha512-QOGY4mjqvF85LDcrzwrQXMcsu1wMTALeL1OHyTkLpN/7cnoDtv0W/qMBjHVq4IKYK6yDH4ZDNdwlonJPhwCGcw== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.36.0" + +"@opentelemetry/instrumentation-lru-memoizer@^0.63.0": + version "0.63.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.63.0.tgz#3ae4808df4635ab384feee59e76566290d02e201" + integrity sha512-DlZRNXfiosmREoLbEGbYuxF70cYXjrYqoaO1sJE167i1+ARWXTq0YMPJ97i53Ws/xkZWllJNYU4mpY2LM2yTlA== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-memcached@^0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.62.0.tgz#561a9a9bdd8990038c8f3c14790a5d7b738b484a" + integrity sha512-kAajd/MtdRBh9PCrMM4fnusFRNPJDU1dv5w9cgnKtMfutRE6K03wBbGSeT3FD1M56sMYWVqPhiKUhfSZCMZrLg== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + "@types/memcached" "^2.2.6" + +"@opentelemetry/instrumentation-mongodb@^0.72.0": + version "0.72.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.72.0.tgz#2b45ccd5e13a399287b3eb593121652810916526" + integrity sha512-WYgGzvlHzdoxHlrhysYtjxE4RC23j/iFZ66hdMJuAoczOWoD/xb6LhRwaz4CM+LKyKtcSIadAjSUyGv2B+SNwg== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + +"@opentelemetry/instrumentation-mongoose@^0.65.0": + version "0.65.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.65.0.tgz#f5bcd9dcf55c09c8a5098e36c3a2fe9f12a65ad4" + integrity sha512-P0iT4oKuinEFZlTIKPJC5hhnmiV9De9lEiLkGzSwnNLdkyHIWug8BfRp5ZROrYAQ9mm47H+WLB/ozvUvgzEvEA== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + +"@opentelemetry/instrumentation-mysql2@^0.65.0": + version "0.65.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.65.0.tgz#1e3007c0ea2c3e2291da0cc9ed7e6d8350b47556" + integrity sha512-Om6BJ/bmFBzNkGbAzj/UV5sCKX6jCGzhTl1Gqgtim/O0dnPE7F2zN65u16Fq3JgyypGAwT2iwh13tYdWkc8/RA== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + "@opentelemetry/sql-common" "^0.42.0" + +"@opentelemetry/instrumentation-mysql@^0.65.0": + version "0.65.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.65.0.tgz#32abbb3df7a1f0647f6cda5857702a06ac351fc2" + integrity sha512-sh1wRjFaTt+8DOhJ0134rFtJoHVUXKI8faIWTbj4zZNw847dzUgmkO8xOluJ9Css0JzT4vCeZofJmq9mQmmESA== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + "@types/mysql" "2.15.27" + +"@opentelemetry/instrumentation-nestjs-core@^0.65.0": + version "0.65.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.65.0.tgz#5602125070609dd21675aafabf6e6954d0f32708" + integrity sha512-/q8fN2M2zGl+gQRaF79dzqvyvVqHAI11c7xAQZy9W1eAtQScNwaQtPm0EUo5+aOgakaTksBrsiHUF0rQS24NsQ== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.30.0" + +"@opentelemetry/instrumentation-net@^0.63.0": + version "0.63.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-net/-/instrumentation-net-0.63.0.tgz#32bf0cc99558f7ad75c18f2e9c4f7459a24084d8" + integrity sha512-fvmVdL4SlsYZf74mq6iLBPd6JJHRAe5utzN7Wt9e4nwa2S3pgExA9poOEmBL1CvIR47MceTA/A8vgVCF23ebbw== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + +"@opentelemetry/instrumentation-openai@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-openai/-/instrumentation-openai-0.17.0.tgz#963ec4b98a5fddc1610e418d7afe595ea1a6c385" + integrity sha512-X3aEZnzj7SJkn1nmqEoD9IljmqENnGrd0vcJngcEApT8uqhFaeimONqKeIzKYvUgC7k4OkAUxC7yfXzxIK1KlA== + dependencies: + "@opentelemetry/api-logs" "^0.219.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.36.0" + +"@opentelemetry/instrumentation-oracledb@^0.44.0": + version "0.44.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-oracledb/-/instrumentation-oracledb-0.44.0.tgz#d310c5b2c6ef3600ec9eca779d792f62168258a6" + integrity sha512-ncEfP4rzuZXBHJJtzWLYctK4Pq/FvZRiASxlFY9CJG9kGjaFTfzBUys0NiP27alYPvpjj0uDAMheDk9nihO8ZA== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.34.0" + "@types/oracledb" "6.5.2" + +"@opentelemetry/instrumentation-pg@^0.71.0": + version "0.71.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.71.0.tgz#81ab2bcd0c614f713fdc5548f9611c707af1eca0" + integrity sha512-jAhfyZeOkEKh3cQ5nm1tNWqHg7HFARyAe+p4BSoDHnB79c1woyEvDKqS11Hj/DjtceP+vrurIfcDs7Fqiy11mQ== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.34.0" + "@opentelemetry/sql-common" "^0.42.0" + "@types/pg" "8.15.6" + "@types/pg-pool" "2.0.7" + +"@opentelemetry/instrumentation-pino@^0.65.0": + version "0.65.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.65.0.tgz#8f30121ef3e009aa278451c1ce628b5c05328a7a" + integrity sha512-p6eh+NRmzi1F+/4QG7XDqRk4ICdCNTsM5vdcwUPnpMie2MddgY1/ENmWvCF9r0Kh6QQRA2kkpnhoJFaiRQ5kVw== + dependencies: + "@opentelemetry/api-logs" "^0.219.0" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-redis@^0.67.0": + version "0.67.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.67.0.tgz#125981100a0f71b63c7103b3d048d70ef1623509" + integrity sha512-TBjO4bPvfGH6bRjJJ+KrJhEqpHg3SWCFZ84MqsTWF639RQZmvkMhT2/DJsBAqqkq33IvHoDJysserqAfZN1s+Q== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/redis-common" "^0.38.3" + "@opentelemetry/semantic-conventions" "^1.27.0" + +"@opentelemetry/instrumentation-restify@^0.64.0": + version "0.64.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.64.0.tgz#62dd0da545f8422d98a1e6944983003cfc984285" + integrity sha512-X+gL4KpfPAx7Y07zKQVtyJPckhCcYJdSlEz0Kq0iR5nkQ8/AVWJ05/txl4voZbZdCuNdn+uLZTtJ60bAQwputQ== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + +"@opentelemetry/instrumentation-router@^0.63.0": + version "0.63.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-router/-/instrumentation-router-0.63.0.tgz#e8918c6143a47a343671d185f9a77c989cc6e91f" + integrity sha512-zIpsZSHGvbaqiEazwUm1X0FkPnLXIwZcL/llu/UplkeGNU58bsw70l2uMqNqTb7J+tzsBC09CLVPty5BhW3X9Q== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + +"@opentelemetry/instrumentation-runtime-node@^0.32.0": + version "0.32.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-runtime-node/-/instrumentation-runtime-node-0.32.0.tgz#572a7dd28de5d8efa24b15d9d82bf40f06c4f794" + integrity sha512-Jo1jSgrHlah3lPpGNPsIpF0q52D5uSLRJrztWUoPc1/Tli2ZWZ+cArgNtcdmiLuKhW21MwYbbcrNw1fbOPeR3A== + dependencies: + "@opentelemetry/api-logs" "^0.219.0" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-socket.io@^0.66.0": + version "0.66.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.66.0.tgz#8c3eef6b0c90a679f0d950eb2d549adb462fe3a7" + integrity sha512-XrZmLkFJktVLd3biQiP8BAhupRwPWLHGIiDCfyDAnWI6borIL0wD6BpwFKKPT/etpu4/5OaeAQ7qs/S+FY9nhQ== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation-tedious@^0.38.0": + version "0.38.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.38.0.tgz#083cf029049f9d1083d122afbc75798ed1c0017c" + integrity sha512-9sRWyIMBHDqJvxRVZ+eQ7jHJ9Iu+DapO27WLZbQF1nyD8xIvEMuDonQ/HnlQiaRdwnqFknXSQTFusJUT3mNVyQ== + dependencies: + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + "@types/tedious" "^4.0.14" + +"@opentelemetry/instrumentation-undici@^0.29.0": + version "0.29.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.29.0.tgz#271745a5acd112b73e75f0a06ec39f0493fab4f7" + integrity sha512-SnA+0XgGc595jtnwFVfWy7Vgfr5hle4D5YKIlm0U4z8aK9YoCZVUn1xAkVZ2evaJyykiDF50FBzr1XZ0uj8CPA== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.219.0" + "@opentelemetry/semantic-conventions" "^1.24.0" + +"@opentelemetry/instrumentation-winston@^0.63.0": + version "0.63.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.63.0.tgz#25d72b3aa43ad2bef83696a82067777a65289312" + integrity sha512-NFMHLYODph0rWGfT/QLv75hCsu1sxVAV79L8HduBCMo181jOTSRZHaqxfrvDtFOsvgYBaiUBa7Ga50w47wM3FA== + dependencies: + "@opentelemetry/api-logs" "^0.219.0" + "@opentelemetry/instrumentation" "^0.219.0" + +"@opentelemetry/instrumentation@0.219.0", "@opentelemetry/instrumentation@^0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.219.0.tgz#84399affd8ee4a12cb199f325c4858654d8dedee" + integrity sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ== + dependencies: + "@opentelemetry/api-logs" "0.219.0" + import-in-the-middle "^3.0.0" + require-in-the-middle "^8.0.0" + +"@opentelemetry/otlp-exporter-base@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz#68f4908f45f6df577d8e1c5b42761645f01cffc5" + integrity sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-transformer" "0.219.0" + +"@opentelemetry/otlp-grpc-exporter-base@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.219.0.tgz#626713dc38a8879cbc95304832971e6daa889248" + integrity sha512-iIk/s8QQu39zpTrRRmsW/Eg3SE2+Hg8tLWepr2FLRgmwUpNd0IpCTLJEHJ77hpt4hgIS8MAh44UYI4xQPZwWlw== + dependencies: + "@grpc/grpc-js" "^1.14.3" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-transformer" "0.219.0" + +"@opentelemetry/otlp-transformer@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz#86305b29f564220666f6e410b7eded57bb5e6c3a" + integrity sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ== + dependencies: + "@opentelemetry/api-logs" "0.219.0" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-logs" "0.219.0" + "@opentelemetry/sdk-metrics" "2.8.0" + "@opentelemetry/sdk-trace-base" "2.8.0" + +"@opentelemetry/propagator-aws-xray@^2.1.4": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-aws-xray/-/propagator-aws-xray-2.2.0.tgz#11ea47c755425526a0280d9a867f7625b8f48943" + integrity sha512-Yjvt2EjL+tfpkVOdKbhTPgpM4SIAez9nG6Q/QjQ3yfcJcjIWp59ph70SLfvmkSL6++3DCnuBG3iWcB18PwWavQ== + +"@opentelemetry/propagator-b3@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-b3/-/propagator-b3-2.8.0.tgz#d8dadd951831cc96bc2e44eadf04d2b3d1a9b320" + integrity sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg== + dependencies: + "@opentelemetry/core" "2.8.0" + +"@opentelemetry/propagator-jaeger@2.8.0", "@opentelemetry/propagator-jaeger@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.9.0.tgz#7fbb5943782aa8cb83d5c41c316cd8be00438747" + integrity sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q== + dependencies: + "@opentelemetry/core" "2.9.0" + +"@opentelemetry/redis-common@^0.38.3": + version "0.38.3" + resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.3.tgz#31a0464a48a991c29408614e3725d94db7c11aee" + integrity sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw== + +"@opentelemetry/resource-detector-alibaba-cloud@^0.34.0": + version "0.34.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.34.0.tgz#c6d3348ec5487310a11159ff0b031e86eeb60630" + integrity sha512-hUs4CK7MbRfffw8y5zR4Mo37MJRR3Zt8Ub4rgMkIk7gL8jozjV7k+zwIk9grz5kGAuD406BDDIghaY8090K4Zw== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/resources" "^2.0.0" + +"@opentelemetry/resource-detector-aws@^2.19.0": + version "2.23.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resource-detector-aws/-/resource-detector-aws-2.23.0.tgz#0668b98b415a4c6cf9ab86b133907d66993e0ffc" + integrity sha512-+wpGu2UafLOGsyQX9LpVtFmSXRit+XlCw/je6c5hHBb0lNk4bHOlbjDG3XaD6yh9hv8eLAlz0HeR0lABUmFr0Q== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/resources" "^2.0.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + +"@opentelemetry/resource-detector-azure@^0.27.0": + version "0.27.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.27.0.tgz#2839694bc1fdfdddda41963fc627180bd25e859c" + integrity sha512-m6HCEmK12QpEcWbKtvGpQtoDVceXtpB14AaaW/t5f6gHeLuGq1QivkuohkvKMkxgAdwIHxEQ8qof3whWBpd8JA== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/resources" "^2.0.0" + "@opentelemetry/semantic-conventions" "^1.37.0" + +"@opentelemetry/resource-detector-container@^0.8.10": + version "0.8.14" + resolved "https://registry.yarnpkg.com/@opentelemetry/resource-detector-container/-/resource-detector-container-0.8.14.tgz#f22e05f2eed7ffbf6c320013a9bbdbb7a1b3395c" + integrity sha512-m7eU89Jb9XnC2ieCQBic8L4ovki+Zw+eSXgdwF5ItN0rWUaCfJQz7Ds/luYqLbxha56wY4IP/jsxnz/WM+a53Q== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/resources" "^2.0.0" + +"@opentelemetry/resource-detector-gcp@^0.54.0": + version "0.54.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.54.0.tgz#88a029ac98b41cbe10af48b1c7e681d9895a37ea" + integrity sha512-u+6sBzQO03QQGFhxjzFa7uNbH6iQpWTcrpWyomxuppH3AN/+1mm3DRVseS1CiRq9VBKrFO0UosWAdD7fWVUrrg== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/resources" "^2.0.0" + gcp-metadata "^8.0.0" + +"@opentelemetry/resources@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.8.0.tgz#9bcb658ab6254f33099f4a95544b40d6f53cc946" + integrity sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/resources@^2.0.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.12.0.tgz#3a0fceb16c44d4d024f25f3f76eb773a7df67847" + integrity sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A== + dependencies: + "@opentelemetry/core" "2.12.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-logs@0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz#002433237908d24fa9e7f17eb4963f25f03d655c" + integrity sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA== + dependencies: + "@opentelemetry/api-logs" "0.219.0" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-metrics@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz#c3f908d9e7e02fb855673fcfd4b298ce279e61f1" + integrity sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/resources" "2.8.0" + +"@opentelemetry/sdk-node@0.219.0", "@opentelemetry/sdk-node@^0.219.0": + version "0.219.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-node/-/sdk-node-0.219.0.tgz#a269bfbe122249bcc2027b67cfdcca4bded254b5" + integrity sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g== + dependencies: + "@opentelemetry/api-logs" "0.219.0" + "@opentelemetry/configuration" "0.219.0" + "@opentelemetry/context-async-hooks" "2.8.0" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/exporter-logs-otlp-grpc" "0.219.0" + "@opentelemetry/exporter-logs-otlp-http" "0.219.0" + "@opentelemetry/exporter-logs-otlp-proto" "0.219.0" + "@opentelemetry/exporter-metrics-otlp-grpc" "0.219.0" + "@opentelemetry/exporter-metrics-otlp-http" "0.219.0" + "@opentelemetry/exporter-metrics-otlp-proto" "0.219.0" + "@opentelemetry/exporter-prometheus" "0.219.0" + "@opentelemetry/exporter-trace-otlp-grpc" "0.219.0" + "@opentelemetry/exporter-trace-otlp-http" "0.219.0" + "@opentelemetry/exporter-trace-otlp-proto" "0.219.0" + "@opentelemetry/exporter-zipkin" "2.8.0" + "@opentelemetry/instrumentation" "0.219.0" + "@opentelemetry/otlp-exporter-base" "0.219.0" + "@opentelemetry/otlp-grpc-exporter-base" "0.219.0" + "@opentelemetry/propagator-b3" "2.8.0" + "@opentelemetry/propagator-jaeger" "2.8.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/sdk-logs" "0.219.0" + "@opentelemetry/sdk-metrics" "2.8.0" + "@opentelemetry/sdk-trace-base" "2.8.0" + "@opentelemetry/sdk-trace-node" "2.8.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-trace-base@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz#ec9c1d69e2e6fba256c9df0c8e8d67d42386d52b" + integrity sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ== + dependencies: + "@opentelemetry/core" "2.8.0" + "@opentelemetry/resources" "2.8.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-trace-node@2.8.0": + version "2.8.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz#cbf68f817a15bf4ca5d9ff9fb60ef3a73f61337d" + integrity sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg== + dependencies: + "@opentelemetry/context-async-hooks" "2.8.0" + "@opentelemetry/core" "2.8.0" + "@opentelemetry/sdk-trace-base" "2.8.0" + +"@opentelemetry/semantic-conventions@^1.24.0", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.36.0", "@opentelemetry/semantic-conventions@^1.37.0": + version "1.43.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz#f3f467e36c27332f0e735ec86cdcd78dd6f27865" + integrity sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg== + +"@opentelemetry/sql-common@^0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sql-common/-/sql-common-0.42.0.tgz#c0b3a28fd63263741c8b27e4ae84fc6395a2f21d" + integrity sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw== + dependencies: + "@opentelemetry/core" "^2.0.0" + +"@paralleldrive/cuid2@^2.2.2": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz#3d62ea9e7be867d3fa94b9897fab5b0ae187d784" + integrity sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw== + dependencies: + "@noble/hashes" "^1.1.5" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== + +"@protobufjs/eventemitter@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz#d512cb26c0ae026091ee2c1167f1be6faf5c842a" + integrity sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg== + +"@protobufjs/fetch@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.1.tgz#4d6fc00c8fb64016a5c81b469d549046350f1065" + integrity sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.2.tgz#78d476333d85d5b1c792e257bca74ba080da49a4" + integrity sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug== + +"@types/aws-lambda@^8.10.155": + version "8.10.164" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.164.tgz#baac93d5bd8aa395411aaf8791acda075d2703e6" + integrity sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg== + +"@types/bunyan@1.8.11": + version "1.8.11" + resolved "https://registry.yarnpkg.com/@types/bunyan/-/bunyan-1.8.11.tgz#0b9e7578a5aa2390faf12a460827154902299638" + integrity sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ== + dependencies: + "@types/node" "*" + +"@types/co-body@^6.1.3": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.3.tgz#201796c6389066b400cfcb4e1ec5c3db798265a2" + integrity sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + +"@types/connect@3.4.38": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + +"@types/memcached@^2.2.6": + version "2.2.10" + resolved "https://registry.yarnpkg.com/@types/memcached/-/memcached-2.2.10.tgz#113f9e3a451d6b5e0a3822e06d9feb52e63e954a" + integrity sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg== + dependencies: + "@types/node" "*" + +"@types/mysql@2.15.27": + version "2.15.27" + resolved "https://registry.yarnpkg.com/@types/mysql/-/mysql-2.15.27.tgz#fb13b0e8614d39d42f40f381217ec3215915f1e9" + integrity sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA== + dependencies: + "@types/node" "*" + +"@types/node@*", "@types/node@>=13.7.0": + version "26.6.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.4.tgz#5aaac1a7337945f7f9a2de4e0d14e7107157e813" + integrity sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg== + dependencies: + undici-types "~8.9.0" + +"@types/oracledb@6.5.2": + version "6.5.2" + resolved "https://registry.yarnpkg.com/@types/oracledb/-/oracledb-6.5.2.tgz#194cd0f13436f9e1e744a6e5e056a7ca400536d5" + integrity sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ== + dependencies: + "@types/node" "*" + +"@types/pg-pool@2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/pg-pool/-/pg-pool-2.0.7.tgz#c17945a74472d9a3beaf8e66d5aa6fc938328734" + integrity sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng== + dependencies: + "@types/pg" "*" + +"@types/pg@*": + version "8.23.1" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.23.1.tgz#7e712ce02cf44ad100862127f10f52e9c6b9d227" + integrity sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^2.2.0" + +"@types/pg@8.15.6": + version "8.15.6" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.15.6.tgz#4df7590b9ac557cbe5479e0074ec1540cbddad9b" + integrity sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^2.2.0" + +"@types/qs@*": + version "6.15.1" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.15.1.tgz#8606884272c63f0db96986bd3548650d8a9388bf" + integrity sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw== + +"@types/tedious@^4.0.14": + version "4.0.14" + resolved "https://registry.yarnpkg.com/@types/tedious/-/tedious-4.0.14.tgz#868118e7a67808258c05158e9cad89ca58a2aec1" + integrity sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw== + dependencies: + "@types/node" "*" + +accepts@^1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + +agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + +ajv@^8.0.0, ajv@^8.17.1: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.2.2: + version "6.4.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.4.0.tgz#69093d6436d30dbb9b04fe7bdcbcfe16c697d8a1" + integrity sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +bignumber.js@^9.0.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" + integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== + +body-parser@^2.2.1, body-parser@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437" + integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw== + dependencies: + bytes "^3.1.2" + content-type "^2.0.0" + debug "^4.4.3" + http-errors "^2.0.1" + iconv-lite "^0.7.2" + on-finished "^2.4.1" + qs "^6.15.2" + raw-body "^3.0.2" + type-is "^2.1.0" + +brace-expansion@^2.0.2: + version "2.1.7" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.7.tgz#14ea3836731823bb24740225ea43b941b436f9d1" + integrity sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g== + dependencies: + balanced-match "^1.0.0" + +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + +bytes@^3.1.2, bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +cjs-module-lexer@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz#4eb71c63d5f70f0b1d8a39a595c134a6692493f2" + integrity sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +co-body@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.2.0.tgz#afd776d60e5659f4eee862df83499698eb1aea1b" + integrity sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA== + dependencies: + "@hapi/bourne" "^3.0.0" + inflation "^2.0.0" + qs "^6.5.2" + raw-body "^2.3.3" + type-is "^1.6.16" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +component-emitter@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-disposition@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.1.tgz#a8b7bbeb2904befdfb6787e5c0c086959f605f9b" + integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== + +content-type@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +content-type@^2.0.0, content-type@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.1.0.tgz#d9389c43c0a8cf6a355db464d21e07092a40493a" + integrity sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag== + +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + +cookies@~0.9.1: + version "0.9.2" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.2.tgz#008155ba8914985360d42b5d5384373cf85aba79" + integrity sha512-8BIbcC6tPZMv2/PD4PrB3CtKks7LmAWhA7FRZK/6/b/WJ2RUz00KCga7gq50S4/RhZ22kxefRFGgsuts0v3eoA== + dependencies: + depd "~2.0.0" + keygrip "~1.1.0" + +cors@^2.8.5: + version "2.8.6" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96" + integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + dependencies: + object-assign "^4" + vary "^1" + +cross-spawn@^7.0.5, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + +dayjs@^1.11.23: + version "1.11.23" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.23.tgz#b0a363506dde5f36cf5075e42ebe8115165a8c79" + integrity sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ== + +debug@4, debug@^4.3.5, debug@^4.3.7, debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-equal@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw== + +deepmerge@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + +depd@^2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +destroy@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +encodeurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +entities@^4.2.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-module-lexer@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-3.0.3.tgz#3c4b64e116d69fe523e332134ef088962d78f288" + integrity sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +etag@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz#b96cbb7dace4f3774f58a9e3b1ae9a4a524872e2" + integrity sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ== + +eventsource@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" + integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== + +eventsource@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989" + integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA== + dependencies: + eventsource-parser "^3.0.1" + +express-rate-limit@^8.2.1: + version "8.7.1" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.7.1.tgz#cb007e811c3173a1dfa79cdb6aacb943f56e62bc" + integrity sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA== + dependencies: + debug "^4.4.3" + ip-address "^10.2.0" + +express@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.1" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +extend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + +fast-uri@^3.0.1, fast-uri@^3.1.6: + version "3.1.8" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.8.tgz#f7db8d942e20eead3cfe93b0beeb4eb0169e938b" + integrity sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg== + +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +form-data@^4.0.5: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + +formidable@^3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.4.tgz#ac9a593b951e829b3298f21aa9a2243932f32ed9" + integrity sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug== + dependencies: + "@paralleldrive/cuid2" "^2.2.2" + dezalgo "^1.0.4" + once "^1.4.0" + +forwarded-parse@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/forwarded-parse/-/forwarded-parse-2.1.2.tgz#08511eddaaa2ddfd56ba11138eee7df117a09325" + integrity sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw== + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + +fresh@~0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +gaxios@7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-7.1.3.tgz#c5312f4254abc1b8ab53aef30c22c5229b80b1e1" + integrity sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ== + dependencies: + extend "^3.0.2" + https-proxy-agent "^7.0.1" + node-fetch "^3.3.2" + rimraf "^5.0.1" + +gcp-metadata@^8.0.0: + version "8.1.4" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-8.1.4.tgz#07f987267431a03ae94f7733cbd088bf92fad0b9" + integrity sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw== + dependencies: + gaxios "7.1.3" + google-logging-utils "1.1.3" + json-bigint "^1.0.0" + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +glob@^10.3.7: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +google-logging-utils@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz#17b71f1f95d266d2ddd356b8f00178433f041b17" + integrity sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2, hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + +hono@^4.11.4, hono@^4.12.34: + version "4.13.13" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.13.13.tgz#8dd5459ce4aa44b3e297f0b350f86b0f6d4a79f0" + integrity sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA== + +htmlparser2@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4" + integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.2.2" + entities "^7.0.1" + +http-assert@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f" + integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w== + dependencies: + deep-equal "~1.0.1" + http-errors "~1.8.0" + +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +http-errors@~1.8.0: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + +https-proxy-agent@^7.0.1: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + +iconv-lite@^0.7.2, iconv-lite@~0.7.0: + version "0.7.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@~0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +import-in-the-middle@^3.0.0: + version "3.5.2" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-3.5.2.tgz#c017ecf4fe1f83da743793ea9321519131cd3135" + integrity sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw== + dependencies: + cjs-module-lexer "^2.2.0" + es-module-lexer "^3.0.2" + module-details-from-path "^1.0.4" + +inflation@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.1.0.tgz#9214db11a47e6f756d111c4f9df96971c60f886c" + integrity sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ== + +inflected@^1.1.6: + version "1.1.7" + resolved "https://registry.yarnpkg.com/inflected/-/inflected-1.1.7.tgz#c393df6e28472d0d77b3082ec3aa2091f4bc96f9" + integrity sha512-3lz7idKIPmKvz0wqlu1PUPSg5strJnCh2v2NldPQy13Fmd6WsWQ5yExDoiIX48lQ9mo8N7ztdDlkZxOauZ/E5g== + +inherits@2.0.4, inherits@~2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ip-address@^10.2.0: + version "10.7.3" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.7.3.tgz#4cb77d274a4e2484a3ea438cfe98dbe0c502e8c5" + integrity sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-plain-object@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.1.0.tgz#22ed7b5e856bb57966348e5b9468dada42be87f4" + integrity sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw== + +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jose@^4.15.9: + version "4.15.9" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.9.tgz#9b68eda29e9a0614c042fa29387196c7dd800100" + integrity sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA== + +jose@^6.1.3: + version "6.2.12" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.12.tgz#65663e146edd010b98ece83f81737d3aa95fd0d7" + integrity sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw== + +json-api-serializer@^2.6.6: + version "2.7.0" + resolved "https://registry.yarnpkg.com/json-api-serializer/-/json-api-serializer-2.7.0.tgz#ca2f36c714ffd1522fb6daa473cb085646b3b8ae" + integrity sha512-Q21X9pIBo53RiJfOat9xlhAOM7aYsgo5Pw+FlSA33Col5pQ3OdAHUwHuZcDGA/lbCrF8FvKDK0g+6GCGxLdhlg== + dependencies: + setimmediate "^1.0.5" + +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-schema-typed@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz#e98ee7b1899ff4a184534d1f167c288c66bbeff4" + integrity sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA== + +jsonapi-serializer@^3.6.9: + version "3.6.9" + resolved "https://registry.yarnpkg.com/jsonapi-serializer/-/jsonapi-serializer-3.6.9.tgz#a2ea0b53a24cf4bb7659232406ed8caa2423e9b2" + integrity sha512-LeRPlP93Mz6+Klu13OKcnXNLvtH1gbeo/yfThqihAMw7vUBCWWs6jHImpR/tQwzAxJi7F1+bfVJxeHoNCrbZiQ== + dependencies: + inflected "^1.1.6" + lodash "^4.16.3" + +jsonwebtoken@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + +keygrip@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" + integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== + dependencies: + tsscmp "1.0.6" + +koa-compose@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" + integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== + +koa@^3.0.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/koa/-/koa-3.2.1.tgz#c99b0c17469418ad5672d3f5832a5610cd6500e4" + integrity sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA== + dependencies: + accepts "^1.3.8" + content-disposition "~1.0.1" + content-type "^1.0.5" + cookies "~0.9.1" + delegates "^1.0.0" + destroy "^1.2.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + fresh "~0.5.2" + http-assert "^1.5.0" + http-errors "^2.0.0" + koa-compose "^4.1.0" + mime-types "^3.0.1" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +launder@^1.7.1: + version "1.7.2" + resolved "https://registry.yarnpkg.com/launder/-/launder-1.7.2.tgz#5ddfddbcb565d208e4c702e94b043a11bfb32653" + integrity sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg== + dependencies: + dayjs "^1.11.23" + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lodash@^4.16.3, lodash@^4.18.0: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + +long@^5.0.0, long@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +luxon@^3.2.1: + version "3.7.2" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.7.2.tgz#d697e48f478553cca187a0f8436aff468e3ba0ba" + integrity sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +media-typer@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.1.tgz#6f035400dfe3ab9d5607bc77546ce30cc2f9c6b8" + integrity sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ== + +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + +methods@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + +mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime-types@^3.0.0, mime-types@^3.0.1, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +minimatch@^9.0.4: + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + dependencies: + brace-expansion "^2.0.2" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" + integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== + +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.19: + version "3.3.20" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.20.tgz#dfd4444d85aa10111ef66ba85ed72e08ba0855d8" + integrity sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +negotiator@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.1.0.tgz#16e003d0db4ac24fd9df168edf871625deeae3df" + integrity sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg== + dependencies: + content-type "^2.1.0" + +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-hash@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + +object-hash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +oidc-token-hash@^5.0.3: + version "5.2.0" + resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz#be8a8885c7e2478d21a674e15afa31f1bcc4a61f" + integrity sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw== + +on-finished@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +openapi3-ts@^4.1.2: + version "4.6.1" + resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-4.6.1.tgz#aaabcab1cf1d17cf754fb49f3041f3cf808e1f38" + integrity sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA== + dependencies: + yaml "^2.9.0" + +openid-client@^5.7.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.7.1.tgz#34cace862a3e6472ed7d0a8616ef73b7fb85a9c3" + integrity sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew== + dependencies: + jose "^4.15.9" + lru-cache "^6.0.0" + object-hash "^2.2.0" + oidc-token-hash "^5.0.3" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== + +parseurl@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-protocol@*: + version "1.16.1" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.1.tgz#87a3951b57002682d63166c029859bd834e64bf1" + integrity sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg== + +pg-types@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +pkce-challenge@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" + integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== + +postcss@^8.3.11: + version "8.5.29" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.29.tgz#086a0ded2c0257c71efb8c68776b477774714b4a" + integrity sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ== + dependencies: + nanoid "^3.3.19" + picocolors "^1.1.1" + source-map-js "^1.2.2" + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz#c40b3da0222c500ff1e51c5d7014b60b79697c7a" + integrity sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +protobufjs@^7.5.5: + version "7.6.6" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.6.6.tgz#7a3923e8e32b0ee2ff689f8eed6e455c090ac67e" + integrity sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.5" + "@protobufjs/eventemitter" "^1.1.1" + "@protobufjs/fetch" "^1.1.1" + "@protobufjs/float" "^1.0.2" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.1" + "@types/node" ">=13.7.0" + long "^5.3.2" + +proxy-addr@^2.0.7: + version "2.0.8" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.8.tgz#624e760fd8ab06b1b8320c2f8506d4422cf8e932" + integrity sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +qs@^6.14.0, qs@^6.14.1, qs@^6.15.2, qs@^6.16.0, qs@^6.5.2: + version "6.16.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" + integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== + dependencies: + es-define-property "^1.0.1" + side-channel "^1.1.1" + +range-parser@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47" + integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw== + +raw-body@^2.3.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" + integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + unpipe "~1.0.0" + +raw-body@^3.0.0, raw-body@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +require-in-the-middle@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz#dbde2587f669398626d56b20c868ab87bf01cce4" + integrity sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ== + dependencies: + debug "^4.3.5" + module-details-from-path "^1.0.3" + +rimraf@^5.0.1: + version "5.0.10" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" + integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== + dependencies: + glob "^10.3.7" + +router@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== + dependencies: + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" + path-to-regexp "^8.0.0" + +safe-buffer@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sanitize-html@2.17.5: + version "2.17.5" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.17.5.tgz#bef1d103773760e1c52a01b1f58178bee9c10556" + integrity sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg== + dependencies: + deepmerge "^4.2.2" + escape-string-regexp "^4.0.0" + htmlparser2 "^10.1.0" + is-plain-object "^5.0.0" + launder "^1.7.1" + parse-srcset "^1.0.2" + postcss "^8.3.11" + +semver@^7.5.4: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + dependencies: + debug "^4.4.3" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.2" + +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.2.0" + +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + +setprototypeof@1.2.0, setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +source-map-js@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.2.tgz#79fb88d97535a21cb27162cb05d211b67acea4ab" + integrity sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw== + +"statuses@>= 1.5.0 < 2": + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + +superagent@^10.3.0: + version "10.4.1" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-10.4.1.tgz#2a461136f302376be9b0e236dc131a46f0974b69" + integrity sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ== + dependencies: + component-emitter "^1.3.1" + cookiejar "^2.1.4" + debug "^4.3.7" + fast-safe-stringify "^2.1.1" + form-data "^4.0.5" + formidable "^3.5.4" + methods "^1.1.2" + mime "2.6.0" + qs "^6.14.1" + +systeminformation@^5.31.6: + version "5.33.15" + resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.33.15.tgz#305ca18a1161c3c1ea57b126192705e60baf7169" + integrity sha512-s5RloL0F7xhN0ZjfHtLGnbf7EW74EI0N8NFL+1xR7Oxz9QA33obNZsl7lyfnJOhN04E/kGFPoJPlvY7TZ5fzmw== + +toidentifier@1.0.1, toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + +type-is@^1.6.16: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +type-is@^2.0.1, type-is@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" + integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== + dependencies: + content-type "^2.0.0" + media-typer "^1.1.0" + mime-types "^3.0.0" + +undici-types@~8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f" + integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +uuid@11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad" + integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ== + +vary@^1, vary@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@^2.0.0, yaml@^2.9.0: + version "2.9.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.1.tgz#c16233fb31944e705cfefaff38795587f57588ce" + integrity sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" + integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +zod-to-json-schema@^3.25.1: + version "3.25.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz#3fa799a7badd554541472fb65843fdc460b2e5aa" + integrity sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA== + +zod@4.6.5, "zod@^3.25 || ^4.0": + version "4.6.5" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.6.5.tgz#e3af3edbdc260239c52f73caad05dbbffe6e82cd" + integrity sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q== diff --git a/packages/gateway/docker/scan-gate.js b/packages/gateway/docker/scan-gate.js new file mode 100644 index 0000000000..1810a45c6a --- /dev/null +++ b/packages/gateway/docker/scan-gate.js @@ -0,0 +1,58 @@ +// Gates the image publish on library (npm) vulnerabilities by ORIGIN, not severity. +// +// Trivy splits findings into OS packages (gated natively in the workflow, blocking — +// only fixable by bumping the base image here) and libraries. Among libraries: +// - @opentelemetry/* are Docker-only (pinned in build-deps-manifest.js): the image +// is the ONLY place they exist and can be fixed → BLOCK. +// - everything else is either the Gateway's npm dependencies, already shipped via the +// npm package (blocking the image would desync GHCR from npm without removing the +// vuln, which is fixed at the source), or the npm CLI the base image bundles +// → REPORT only. +// +// Usage: node scan-gate.js (exits 1 if a blocking +// OTel vuln is found; always prints every library finding for visibility) + +const fs = require('fs'); + +const file = process.argv[2]; +if (!file) { + console.error('usage: node scan-gate.js '); + process.exit(1); +} + +const report = JSON.parse(fs.readFileSync(file, 'utf8')); +const findings = (report.Results || []) + .flatMap(r => r.Vulnerabilities || []) + .map(v => ({ + id: v.VulnerabilityID, + pkg: v.PkgName, + severity: v.Severity, + installed: v.InstalledVersion, + fixed: v.FixedVersion || '(none)', + })); + +const lines = ['## Image dependency scan (CRITICAL,HIGH, fixable)', '']; +if (findings.length === 0) { + lines.push('No library vulnerabilities found.'); +} else { + lines.push('| Package | Severity | ID | Installed | Fixed |', '|---|---|---|---|---|'); + for (const f of findings) { + lines.push(`| ${f.pkg} | ${f.severity} | ${f.id} | ${f.installed} | ${f.fixed} |`); + } +} +const summary = lines.join('\n'); +console.log(summary); +if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + +// Block only on Docker-only OTel packages — everything else is report-only. +const blocking = findings.filter(f => f.pkg.startsWith('@opentelemetry/')); +if (blocking.length) { + console.error( + `\n::error::${blocking.length} fixable vuln(s) in Docker-only @opentelemetry packages must be ` + + 'fixed here (bump versions in build-deps-manifest.js): ' + + blocking.map(f => `${f.pkg}@${f.installed} (${f.id})`).join(', '), + ); + process.exit(1); +} + +console.log('\nNo blocking (Docker-only) library vulnerabilities — npm-shared deps are report-only.'); diff --git a/packages/gateway/docker/smoke-test.sh b/packages/gateway/docker/smoke-test.sh new file mode 100644 index 0000000000..83315f6c60 --- /dev/null +++ b/packages/gateway/docker/smoke-test.sh @@ -0,0 +1,244 @@ +#!/bin/sh +set -eu + +IMAGE="${1:?usage: smoke-test.sh }" +CLI=/app/packages/gateway/dist/cli.js +PORT=13931 +STUB_PORT=13932 +TMP_DIR=$(mktemp -d) + +docker run --rm "$IMAGE" --version +docker run --rm "$IMAGE" --help > "$TMP_DIR/help.txt" +grep -q "Usage: forest-gateway" "$TMP_DIR/help.txt" + +docker run --rm --entrypoint node "$IMAGE" -e "require('$CLI')" + +docker run --rm --entrypoint node "$IMAGE" \ + -e "require('fs').accessSync('/app/node_modules/@forestadmin/agent-bff/dist/docs/redoc.standalone.js')" + +docker run --rm "$IMAGE" openapi > "$TMP_DIR/openapi.json" +grep -q '"openapi"' "$TMP_DIR/openapi.json" + +docker run --rm --entrypoint sh "$IMAGE" -c \ + "node $CLI openapi --output && test -s /app/openapi.json" + +CONTAINER="" +STUB_PID="" +cleanup() { + [ -n "$CONTAINER" ] && docker logs "$CONTAINER" 2>&1 || true + [ -n "$CONTAINER" ] && docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + [ -n "$STUB_PID" ] && kill "$STUB_PID" 2>/dev/null || true + [ -n "$STUB_PID" ] && wait "$STUB_PID" 2>/dev/null || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +run_probed() { + docker run -d --health-interval=1s --health-start-period=0s --health-retries=1 "$@" "$IMAGE" +} + +wait_health() { + expected="$1" + health="" + attempt=0 + while [ "$attempt" -lt 90 ]; do + health=$(docker inspect "$CONTAINER" --format '{{.State.Health.Status}}') + [ "$health" = "$expected" ] && return 0 + attempt=$((attempt + 1)) + sleep 1 + done + echo "::error::the Docker HEALTHCHECK reported '$health', expected '$expected'" + docker inspect "$CONTAINER" --format '{{json .State.Health}}' || true + return 1 +} + +wait_probe_output() { + expected="$1" + output="" + attempt=0 + while [ "$attempt" -lt 90 ]; do + output=$(docker inspect "$CONTAINER" --format '{{with .State.Health}}{{range .Log}}{{.Output}}{{end}}{{end}}') + case "$output" in *"$expected"*) return 0 ;; esac + attempt=$((attempt + 1)) + sleep 1 + done + echo "::error::the Docker HEALTHCHECK never reported '$expected'" + docker inspect "$CONTAINER" --format '{{json .State.Health}}' || true + return 1 +} + +wait_http() { + url="$1" + out="$2" + until_code="$3" + code="" + attempt=0 + while [ "$attempt" -lt 90 ]; do + code=$(curl -s -o "$out" -w '%{http_code}' "$url" || true) + if [ -n "$until_code" ]; then + [ "$code" = "$until_code" ] && break + else + [ "$code" != "000" ] && [ -n "$code" ] && break + fi + attempt=$((attempt + 1)) + sleep 1 + done + echo "$code" +} + +expect_in() { + file="$1" + needle="$2" + message="$3" + if ! grep -q "$needle" "$file"; then + echo "::error::$message" + cat "$file" + exit 1 + fi +} + +stop_container() { + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + CONTAINER="" +} + +CONTAINER=$(run_probed -p "127.0.0.1:$PORT:3931" \ + -e FOREST_GATEWAY_SERVICES=mcp,api \ + -e FOREST_AUTH_SECRET=smoke-test \ + -e FOREST_ENV_SECRET="$(openssl rand -hex 32)" \ + -e FOREST_SERVER_URL=http://127.0.0.1:1 \ + -e FOREST_APP_URL=http://127.0.0.1:1 \ + -e FOREST_AGENT_URL=http://127.0.0.1:1 \ + -e OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318) + +status=$(wait_http "http://127.0.0.1:$PORT/health" "$TMP_DIR/health.json" "") +docker logs "$CONTAINER" > "$TMP_DIR/boot.log" 2>&1 + +if grep -qiE "Cannot find module|MODULE_NOT_FOUND" "$TMP_DIR/boot.log"; then + echo "::error::module resolution failure in the image" + exit 1 +fi +expect_in "$TMP_DIR/boot.log" "Forest Gateway started" "the Gateway did not reach startup — boot failure" +expect_in "$TMP_DIR/boot.log" "OpenTelemetry tracing enabled" \ + "the OTel SDK did not initialise — packages missing from the image?" +grep "OpenTelemetry tracing enabled" "$TMP_DIR/boot.log" > "$TMP_DIR/otel.log" +expect_in "$TMP_DIR/otel.log" "forest-gateway" "traces are not named forest-gateway" + +if [ "$status" != "200" ]; then + echo "::error::/health answered '$status', expected 200" + cat "$TMP_DIR/health.json" 2>/dev/null || true + exit 1 +fi +expect_in "$TMP_DIR/health.json" '"mcp":"ok"' "/health does not report the MCP as ok" +expect_in "$TMP_DIR/health.json" '"oauth":false' \ + "/health does not report API OAuth as unconfigured without a token encryption key" + +for path in /api/docs /api/docs/redoc.standalone.js; do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT$path") + if [ "$code" != "200" ]; then + echo "::error::$path answered '$code', expected 200" + exit 1 + fi +done + +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$PORT/mcp") +if [ "$code" != "401" ]; then + echo "::error::POST /mcp without a token answered '$code', expected 401" + exit 1 +fi + +wait_health healthy +echo "mcp,api without PORT: HEALTHCHECK healthy on 3931" +stop_container + +CONTAINER=$(run_probed \ + -e FOREST_GATEWAY_SERVICES=mcp \ + -e FOREST_AUTH_SECRET=smoke-test \ + -e FOREST_ENV_SECRET="$(openssl rand -hex 32)" \ + -e FOREST_SERVER_URL=http://127.0.0.1:1) +wait_health healthy +echo "mcp without PORT: HEALTHCHECK healthy on 3931" +stop_container + +CONTAINER=$(run_probed \ + -e FOREST_GATEWAY_SERVICES=api \ + -e FOREST_AUTH_SECRET=smoke-test \ + -e FOREST_ENV_SECRET="$(openssl rand -hex 32)" \ + -e FOREST_SERVER_URL=http://127.0.0.1:1 \ + -e FOREST_APP_URL=http://127.0.0.1:1 \ + -e FOREST_AGENT_URL=http://127.0.0.1:1) +wait_health healthy +echo "api without PORT: HEALTHCHECK healthy on 3450" +stop_container + +CONTAINER=$(run_probed \ + -e FOREST_GATEWAY_SERVICES=api \ + -e HTTP_PORT=8080 \ + -e FOREST_AUTH_SECRET=smoke-test \ + -e FOREST_ENV_SECRET="$(openssl rand -hex 32)" \ + -e FOREST_SERVER_URL=http://127.0.0.1:1 \ + -e FOREST_APP_URL=http://127.0.0.1:1 \ + -e FOREST_AGENT_URL=http://127.0.0.1:1) +wait_health healthy +echo "api with HTTP_PORT=8080: HEALTHCHECK healthy" +stop_container + +CONTAINER=$(run_probed \ + -e FOREST_GATEWAY_SERVICES=mcp \ + -e FOREST_SERVER_URL=http://127.0.0.1:1) +wait_probe_output "/health on port 3931 answered 503" +wait_health unhealthy +echo "mcp without its secrets: HEALTHCHECK unhealthy on a 503" +stop_container + +STUB_DIR="$TMP_DIR/stub" +mkdir -p "$STUB_DIR/liana" +printf '{"data":{"id":1}}' > "$STUB_DIR/liana/environment" +python3 -m http.server "$STUB_PORT" --bind 0.0.0.0 --directory "$STUB_DIR" >/dev/null 2>&1 & +STUB_PID=$! + +stub_up=$(wait_http "http://127.0.0.1:$STUB_PORT/liana/environment" /dev/null 200) +if [ "$stub_up" != "200" ]; then + echo "::error::the Forest server stub did not come up on $STUB_PORT (answered '$stub_up')" + exit 1 +fi + +CONTAINER=$(docker run -d -p "127.0.0.1:$PORT:3931" \ + --add-host "smoke-host:host-gateway" \ + -e FOREST_GATEWAY_SERVICES=mcp,api \ + -e FOREST_AUTH_SECRET=smoke-test \ + -e FOREST_ENV_SECRET="$(openssl rand -hex 32)" \ + -e FOREST_SERVER_URL="http://smoke-host:$STUB_PORT" \ + -e FOREST_APP_URL=http://127.0.0.1:1 \ + -e FOREST_AGENT_URL=http://127.0.0.1:1 \ + -e FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY="$(openssl rand -base64 32)" \ + -e OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \ + "$IMAGE") + +status=$(wait_http "http://127.0.0.1:$PORT/health" "$TMP_DIR/health-ok.json" 200) +if [ "$status" != "200" ]; then + echo "::error::/health answered '$status' with a complete configuration, expected 200" + cat "$TMP_DIR/health-ok.json" 2>/dev/null || true + exit 1 +fi +expect_in "$TMP_DIR/health-ok.json" '"healthy":true' "/health body is not the healthy payload" +expect_in "$TMP_DIR/health-ok.json" '"oauth":true' \ + "/health does not report API OAuth as configured with a complete configuration" + +STOP_STARTED=$(date +%s) +docker stop -t 30 "$CONTAINER" >/dev/null +STOP_ELAPSED=$(( $(date +%s) - STOP_STARTED )) +STOP_CODE=$(docker inspect "$CONTAINER" --format '{{.State.ExitCode}}') + +if [ "$STOP_CODE" != "0" ]; then + echo "::error::the container exited $STOP_CODE on docker stop, expected 0 (137 means it was SIGKILLed)" + exit 1 +fi +if [ "$STOP_ELAPSED" -gt 8 ]; then + echo "::error::shutdown took ${STOP_ELAPSED}s; the shutdown is no longer bounded" + exit 1 +fi + +echo "graceful shutdown with an unreachable collector: exit 0 in ${STOP_ELAPSED}s" +stop_container +echo "smoke test passed for $IMAGE" diff --git a/packages/gateway/jest.config.ts b/packages/gateway/jest.config.ts new file mode 100644 index 0000000000..a8c1238c4c --- /dev/null +++ b/packages/gateway/jest.config.ts @@ -0,0 +1,7 @@ +import jestConfig from '../../jest.config'; + +export default { + ...jestConfig, + collectCoverageFrom: ['/src/**/*.ts'], + testMatch: ['/test/**/*.test.ts'], +}; diff --git a/packages/gateway/package.json b/packages/gateway/package.json new file mode 100644 index 0000000000..0fe62b7116 --- /dev/null +++ b/packages/gateway/package.json @@ -0,0 +1,41 @@ +{ + "name": "@forestadmin/gateway", + "version": "0.0.0", + "description": "Serves the Forest Admin MCP and API services on one port, for agents that cannot embed them", + "main": "dist/index.js", + "bin": { + "forest-gateway": "dist/cli.js" + }, + "license": "GPL-3.0", + "engines": { + "node": ">=22.12.0" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ForestAdmin/agent-nodejs.git", + "directory": "packages/gateway" + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts" + ], + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "start": "node dist/cli.js", + "clean": "rm -rf coverage dist", + "lint": "eslint src test", + "test": "jest" + }, + "dependencies": { + "@forestadmin/agent-bff": "^1.40.0", + "@forestadmin/forestadmin-client": "^1.43.5", + "@forestadmin/mcp-server": "^1.28.0" + }, + "devDependencies": { + "supertest": "^7.1.3" + } +} diff --git a/packages/gateway/src/cli-dispatch.ts b/packages/gateway/src/cli-dispatch.ts new file mode 100644 index 0000000000..de70b6b6b4 --- /dev/null +++ b/packages/gateway/src/cli-dispatch.ts @@ -0,0 +1,103 @@ +import type { BFFHttpServer, Logger } from '@forestadmin/agent-bff'; + +import { + DEFAULT_OUTPUT_FILE, + OUTPUT_FLAG, + createConsoleLogger, + parseOutputOption, + renderOpenApi, + writeOutputFile, +} from '@forestadmin/agent-bff'; + +import { apiBasePath, parseOpenApiEnv } from './gateway-env'; +import runGateway from './run-gateway'; +import version from './version'; + +export const USAGE = `Usage: forest-gateway [command] + +Commands: + (none) Start the Gateway, configured from the environment. + FOREST_GATEWAY_SERVICES lists what it serves: mcp, api or mcp,api. + openapi Write the API's OpenAPI document to stdout, its paths under + FOREST_GATEWAY_BASE_PATH/api, its server FOREST_GATEWAY_API_PUBLIC_URL + when set. + --output [file] Write to a file instead of stdout, defaulting + to ${DEFAULT_OUTPUT_FILE} in the current directory. + +Options: + -h, --help Show this help and exit. + -v, --version Show the package version and exit.`; + +export const HINT = "Run 'forest-gateway --help' for usage."; + +const HELP_FLAGS = new Set(['-h', '--help']); +const VERSION_FLAGS = new Set(['-v', '--version']); + +export interface DispatchOutcome { + exitCode: number; + server?: BFFHttpServer; +} + +function rejectCli(reason: string): DispatchOutcome { + process.stderr.write(`${reason}\n${HINT}\n`); + + return { exitCode: 1 }; +} + +async function renderGatewayOpenApi(env: NodeJS.ProcessEnv, logger: Logger): Promise { + const { basePath, api, warnings } = parseOpenApiEnv(env); + + warnings.forEach(warning => logger('Warn', warning)); + + return renderOpenApi(api.env, logger, { basePath: apiBasePath(basePath), labels: api.labels }); +} + +export default async function dispatchCli( + argv: string[], + env: NodeJS.ProcessEnv, + logger?: Logger, +): Promise { + const [subcommand, ...rest] = argv; + + if (subcommand === undefined) { + return { exitCode: 0, server: await runGateway(env, logger) }; + } + + if (HELP_FLAGS.has(subcommand)) { + process.stdout.write(`${USAGE}\n`); + + return { exitCode: 0 }; + } + + if (VERSION_FLAGS.has(subcommand)) { + process.stdout.write(`${version}\n`); + + return { exitCode: 0 }; + } + + if (subcommand !== 'openapi') { + return rejectCli( + subcommand === OUTPUT_FLAG + ? `${OUTPUT_FLAG} only applies to the openapi command` + : `Unknown command: ${subcommand}`, + ); + } + + const { file, extras } = parseOutputOption(rest); + + if (extras.length > 0) { + return rejectCli( + `openapi accepts only --output, got: ${extras.map(extra => JSON.stringify(extra)).join(' ')}`, + ); + } + + const document = await renderGatewayOpenApi(env, logger ?? createConsoleLogger()); + + if (file !== undefined) { + return writeOutputFile(file, document); + } + + process.stdout.write(document); + + return { exitCode: 0 }; +} diff --git a/packages/gateway/src/cli.ts b/packages/gateway/src/cli.ts new file mode 100644 index 0000000000..71815cbc10 --- /dev/null +++ b/packages/gateway/src/cli.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node +/* istanbul ignore file */ +import dispatchCli from './cli-dispatch'; + +if (require.main === module) { + dispatchCli(process.argv.slice(2), process.env) + .then(({ exitCode }) => { + if (exitCode !== 0) process.exitCode = exitCode; + }) + .catch(error => { + process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/gateway/src/env-aliases.ts b/packages/gateway/src/env-aliases.ts new file mode 100644 index 0000000000..7a947edba1 --- /dev/null +++ b/packages/gateway/src/env-aliases.ts @@ -0,0 +1,59 @@ +import { ConfigurationError } from '@forestadmin/agent-bff'; + +export interface Resolution { + key: string; + value?: string; +} + +export interface ResolveOptions { + blankIsSet?: boolean; +} + +function isSet(value: string | undefined): value is string { + return value !== undefined && value.trim() !== ''; +} + +function isNonEmpty(value: string | undefined): value is string { + return value !== undefined && value !== ''; +} + +export default class EnvAliases { + readonly warnings: string[] = []; + + constructor(private readonly env: NodeJS.ProcessEnv) {} + + resolve( + name: string, + aliases: readonly string[] = [], + { blankIsSet = false }: ResolveOptions = {}, + ): Resolution { + const isUsed = blankIsSet ? isNonEmpty : isSet; + const usedAliases = aliases.filter(alias => isUsed(this.env[alias])); + + if (isUsed(this.env[name])) { + usedAliases.forEach(alias => this.warnings.push(`${alias} is ignored: ${name} is set`)); + + return { key: name, value: this.env[name] }; + } + + if (new Set(usedAliases.map(alias => this.env[alias]?.trim())).size > 1) { + throw new ConfigurationError( + `${usedAliases.join(' and ')} disagree: set ${name} to the value the Gateway should use.`, + ); + } + + usedAliases.forEach(alias => + this.warnings.push(`${alias} is a legacy name: use ${name} instead`), + ); + + return usedAliases.length > 0 + ? { key: usedAliases[0], value: this.env[usedAliases[0]] } + : { key: name }; + } + + ignore(keys: readonly string[], reason: string): void { + keys + .filter(key => isSet(this.env[key])) + .forEach(key => this.warnings.push(`${key} is ignored: ${reason}`)); + } +} diff --git a/packages/gateway/src/gateway-env.ts b/packages/gateway/src/gateway-env.ts new file mode 100644 index 0000000000..a847dd9913 --- /dev/null +++ b/packages/gateway/src/gateway-env.ts @@ -0,0 +1,281 @@ +import type { Resolution } from './env-aliases'; +import type { ConfigKey, ConfigLabels } from '@forestadmin/agent-bff'; +import type { McpEnv, McpEnvLabels } from '@forestadmin/mcp-server'; + +import { CONFIG_KEYS, ConfigurationError, parsePublicUrl } from '@forestadmin/agent-bff'; +import { normalizeDomainList, parseDomainList } from '@forestadmin/forestadmin-client'; +import { MCP_LISTENER_ENV, normalizeMountPath } from '@forestadmin/mcp-server'; + +import EnvAliases from './env-aliases'; + +export type Service = 'mcp' | 'api'; + +export const SERVICES: readonly Service[] = ['mcp', 'api']; + +export const SERVICES_VAR = 'FOREST_GATEWAY_SERVICES'; +export const BASE_PATH_VAR = 'FOREST_GATEWAY_BASE_PATH'; +export const PUBLIC_URL_VAR = 'FOREST_GATEWAY_URL'; +export const PORT_VAR = 'PORT'; +export const AGENT_URL_VAR = 'FOREST_AGENT_URL'; +export const API_PUBLIC_URL_VAR = 'FOREST_GATEWAY_API_PUBLIC_URL'; +export const ALLOWED_OAUTH_CLIENTS_VAR = 'FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS'; + +const API_VAR_PREFIX = 'FOREST_GATEWAY_API_'; +const BFF_VAR_PREFIX = 'BFF_'; + +const BFF_LISTENER_ENV = { + port: 'HTTP_PORT', + publicUrl: 'BFF_PUBLIC_URL', +} as const satisfies Record; + +const PORT_ALIASES = [MCP_LISTENER_ENV.port, BFF_LISTENER_ENV.port]; +const PUBLIC_URL_ALIASES = [MCP_LISTENER_ENV.publicUrl]; +const AGENT_URL_ALIASES = ['AGENT_URL']; +const ALLOWED_OAUTH_CLIENTS_ALIASES = ['FOREST_MCP_ALLOWED_OAUTH_CLIENTS']; + +const MCP_LABELS: Partial = { + accessTokenTtl: 'FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS', + refreshTokenTtl: 'FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS', +}; + +export const DEFAULT_MCP_PORT = 3931; +export const DEFAULT_API_PORT = 3450; + +const MAX_PORT = 65535; +const DECIMAL_INTEGER = /^\d+$/; +const HTTP_PROTOCOLS = ['http:', 'https:']; + +export interface McpSettings { + env: McpEnv; + labels: Partial; +} + +export interface ApiSettings { + env: NodeJS.ProcessEnv; + labels: ConfigLabels; + allowedOAuthClients?: string[]; +} + +export interface GatewayEnv { + services: Set; + basePath: string; + port: number; + publicUrl?: string; + mcp?: McpSettings; + api?: ApiSettings; + warnings: string[]; +} + +export interface OpenApiEnv { + basePath: string; + api: ApiSettings; + warnings: string[]; +} + +function isService(value: string): value is Service { + return (SERVICES as readonly string[]).includes(value); +} + +export function parseServices(raw: string | undefined): Set { + const entries = (raw ?? '') + .split(',') + .map(entry => entry.trim()) + .filter(entry => entry !== ''); + + if (entries.length === 0 || !entries.every(isService)) { + throw new ConfigurationError( + `Invalid ${SERVICES_VAR} "${raw ?? ''}": list the services to serve, ` + + `among ${SERVICES.map(service => `"${service}"`).join(', ')} (e.g. "mcp,api").`, + ); + } + + return new Set(entries as Service[]); +} + +export function apiBasePath(basePath: string): string { + return `${basePath}/api`; +} + +function parsePort({ key, value }: Resolution, fallback: number): number { + const trimmed = value?.trim(); + + if (!trimmed) return fallback; + + const port = DECIMAL_INTEGER.test(trimmed) ? Number(trimmed) : NaN; + + if (Number.isNaN(port) || port > MAX_PORT) { + throw new ConfigurationError( + `Invalid ${key} "${value}": expected an integer between 0 and ${MAX_PORT}.`, + ); + } + + return port; +} + +function parseGatewayUrl({ key, value }: Resolution): string | undefined { + const trimmed = value?.trim(); + + if (!trimmed) return undefined; + + const parsed = URL.canParse(trimmed) ? new URL(trimmed) : undefined; + const isBareHttpOrigin = + parsed && HTTP_PROTOCOLS.includes(parsed.protocol) && parsed.href === `${parsed.origin}/`; + + if (!isBareHttpOrigin) { + throw new ConfigurationError( + `Invalid ${key}: expected an http(s) origin with no path, query, fragment or ` + + 'credentials, e.g. https://gateway.example.com', + ); + } + + return parsed.origin; +} + +function assertAgentUrl({ key, value }: Resolution): void { + if (value === undefined) return; + + const parsed = !/\s/.test(value) && URL.canParse(value) ? new URL(value) : undefined; + + if (!parsed || !HTTP_PROTOCOLS.includes(parsed.protocol) || parsed.search || parsed.hash) { + throw new ConfigurationError( + `Invalid ${key}: expected an absolute http(s) URL with no query string or fragment.`, + ); + } +} + +function parseAllowedOAuthClients({ key, value }: Resolution): string[] | undefined { + if (value === undefined) return undefined; + + return normalizeDomainList(parseDomainList(value) ?? [], key); +} + +function assertApiPublicUrl(value: string | undefined, key: string, basePath: string): void { + const url = parsePublicUrl(value, key); + const suffix = apiBasePath(basePath); + + if (url !== undefined && !new URL(url).pathname.endsWith(suffix)) { + throw new ConfigurationError( + `Invalid configuration: ${key} must end with ${suffix}, where the Gateway serves the API.`, + ); + } +} + +function resolveApi( + env: NodeJS.ProcessEnv, + aliases: EnvAliases, + basePath: string, + agentUrl: Resolution, +): ApiSettings { + const apiEnv: NodeJS.ProcessEnv = { + ...env, + AGENT_URL: agentUrl.value, + [BFF_LISTENER_ENV.port]: undefined, + }; + const labels: ConfigLabels = { AGENT_URL: agentUrl.key }; + const bffKeys = CONFIG_KEYS.filter((key: ConfigKey) => key.startsWith(BFF_VAR_PREFIX)); + + for (const bffKey of bffKeys) { + const { key, value } = aliases.resolve( + `${API_VAR_PREFIX}${bffKey.slice(BFF_VAR_PREFIX.length)}`, + [bffKey], + ); + + apiEnv[bffKey] = value; + labels[bffKey] = key; + } + + assertApiPublicUrl(apiEnv.BFF_PUBLIC_URL, labels.BFF_PUBLIC_URL ?? API_PUBLIC_URL_VAR, basePath); + + return { env: apiEnv, labels }; +} + +function resolveAgentUrl(aliases: EnvAliases, withApi: boolean): Resolution { + if (!withApi) { + aliases.ignore(AGENT_URL_ALIASES, `the api service is off: use ${AGENT_URL_VAR}`); + } + + const agentUrl = aliases.resolve(AGENT_URL_VAR, withApi ? AGENT_URL_ALIASES : []); + assertAgentUrl(agentUrl); + + return agentUrl; +} + +export function parseOpenApiEnv(env: NodeJS.ProcessEnv): OpenApiEnv { + const basePath = normalizeMountPath(env[BASE_PATH_VAR], BASE_PATH_VAR); + const aliases = new EnvAliases(env); + const api = resolveApi(env, aliases, basePath, resolveAgentUrl(aliases, true)); + + return { basePath, api, warnings: aliases.warnings }; +} + +function resolvePort(aliases: EnvAliases, withMcp: boolean): number { + return parsePort( + aliases.resolve(PORT_VAR, PORT_ALIASES), + withMcp ? DEFAULT_MCP_PORT : DEFAULT_API_PORT, + ); +} + +export function resolveListenPort(env: NodeJS.ProcessEnv): number { + return resolvePort(new EnvAliases(env), parseServices(env[SERVICES_VAR]).has('mcp')); +} + +export default function parseGatewayEnv(env: NodeJS.ProcessEnv): GatewayEnv { + const services = parseServices(env[SERVICES_VAR]); + const withMcp = services.has('mcp'); + const withApi = services.has('api'); + const basePath = normalizeMountPath(env[BASE_PATH_VAR], BASE_PATH_VAR); + const aliases = new EnvAliases(env); + const port = resolvePort(aliases, withMcp); + + if (!withMcp) { + aliases.ignore( + [PUBLIC_URL_VAR, ...PUBLIC_URL_ALIASES], + `the mcp service is off: the API reads ${API_PUBLIC_URL_VAR}`, + ); + } + + const publicUrl = withMcp + ? parseGatewayUrl(aliases.resolve(PUBLIC_URL_VAR, PUBLIC_URL_ALIASES)) + : undefined; + const agentUrl = resolveAgentUrl(aliases, withApi); + const allowedOAuthClients = aliases.resolve( + ALLOWED_OAUTH_CLIENTS_VAR, + ALLOWED_OAUTH_CLIENTS_ALIASES, + { blankIsSet: true }, + ); + const allowedClientDomains = parseAllowedOAuthClients(allowedOAuthClients); + + if (withMcp && port === 0 && !publicUrl) { + throw new ConfigurationError( + `${PORT_VAR}=0 binds a port chosen by the OS, which cannot be in the url advertised to MCP ` + + `clients. Set ${PUBLIC_URL_VAR} to the public url they should use.`, + ); + } + + if (withMcp && !publicUrl) { + aliases.warnings.push( + `${PUBLIC_URL_VAR} is not set: MCP clients are told http://localhost:${port}`, + ); + } + + const mcp: McpSettings | undefined = withMcp + ? { + env: { + ...env, + FOREST_AGENT_URL: agentUrl.value, + FOREST_MCP_ALLOWED_OAUTH_CLIENTS: allowedOAuthClients.value, + [MCP_LISTENER_ENV.port]: undefined, + [MCP_LISTENER_ENV.publicUrl]: undefined, + }, + labels: { ...MCP_LABELS, agentUrl: agentUrl.key }, + } + : undefined; + const api: ApiSettings | undefined = withApi + ? { + ...resolveApi(env, aliases, basePath, agentUrl), + allowedOAuthClients: allowedClientDomains, + } + : undefined; + + return { services, basePath, port, publicUrl, mcp, api, warnings: aliases.warnings }; +} diff --git a/packages/gateway/src/healthcheck.ts b/packages/gateway/src/healthcheck.ts new file mode 100644 index 0000000000..ab0c369d76 --- /dev/null +++ b/packages/gateway/src/healthcheck.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import http from 'http'; + +import { resolveListenPort } from './gateway-env'; +import { HEALTH_PATH } from './standalone-handler'; + +const HTTP_OK = 200; +const PROBE_TIMEOUT_MS = 4000; + +export default async function probeHealth( + env: NodeJS.ProcessEnv, + timeoutMs = PROBE_TIMEOUT_MS, +): Promise { + let port: number; + + try { + port = resolveListenPort(env); + } catch (error) { + return (error as Error).message; + } + + if (port === 0) return 'PORT=0 binds a port chosen by the OS, which cannot be probed'; + + return new Promise(resolve => { + const request = http.get( + { host: 'localhost', port, path: HEALTH_PATH, timeout: timeoutMs }, + response => { + response.resume(); + resolve( + response.statusCode === HTTP_OK + ? undefined + : `${HEALTH_PATH} on port ${port} answered ${response.statusCode}`, + ); + }, + ); + + request.on('timeout', () => request.destroy(new Error(`timed out after ${timeoutMs}ms`))); + request.on('error', (error: NodeJS.ErrnoException) => + resolve(`${HEALTH_PATH} on port ${port}: ${error.code ?? error.message}`), + ); + }); +} + +/* istanbul ignore next */ +if (require.main === module) { + probeHealth(process.env).then(failure => { + if (failure === undefined) return; + + process.stderr.write(`${failure}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts new file mode 100644 index 0000000000..f305ebe2a7 --- /dev/null +++ b/packages/gateway/src/index.ts @@ -0,0 +1,12 @@ +export { default as runGateway, GATEWAY_NAME } from './run-gateway'; +export { default as dispatchCli } from './cli-dispatch'; +export { + default as parseGatewayEnv, + parseServices, + DEFAULT_API_PORT, + DEFAULT_MCP_PORT, +} from './gateway-env'; +export type { GatewayEnv, Service } from './gateway-env'; +export { GATEWAY_VERSION_HEADER } from './standalone-handler'; +export type { GatewayHealth } from './standalone-handler'; +export { default as version } from './version'; diff --git a/packages/gateway/src/run-gateway.ts b/packages/gateway/src/run-gateway.ts new file mode 100644 index 0000000000..ea5d07e462 --- /dev/null +++ b/packages/gateway/src/run-gateway.ts @@ -0,0 +1,186 @@ +import type { ApiSettings, GatewayEnv, McpSettings } from './gateway-env'; +import type { ApiHealth, McpHealth } from './standalone-handler'; +import type { Bff, Logger } from '@forestadmin/agent-bff'; +import type { GatewayHandler } from '@forestadmin/mcp-server'; + +import { + BFFHttpServer, + buildBff, + claimsBffPath, + createConsoleLogger, + installShutdownHandlers, + parseConfig, +} from '@forestadmin/agent-bff'; +import { + ForestMCPServer, + createGatewaySwitch, + loadFileUploads, + makeIsMcpRoute, + parseMcpEnv, +} from '@forestadmin/mcp-server'; + +import parseGatewayEnv, { apiBasePath } from './gateway-env'; +import createStandaloneHandler, { + createUnavailableMcpHandler, + describeGatewayHealth, +} from './standalone-handler'; +import version from './version'; + +export const GATEWAY_NAME = 'Forest Gateway'; +export const GATEWAY_API_NAME = 'Gateway API'; + +const MCP_REQUIRED_VARS = ['FOREST_ENV_SECRET', 'FOREST_AUTH_SECRET'] as const; + +interface McpService { + handler: GatewayHandler; + health: McpHealth; +} + +interface ApiService { + handler: GatewayHandler; + bff: Bff; +} + +function missingMcpVars(env: McpSettings['env']): string[] { + return MCP_REQUIRED_VARS.filter(name => !env[name]?.trim()); +} + +async function buildMcpService( + settings: McpSettings, + gateway: GatewayEnv, + logger: Logger, +): Promise { + const { options, uploadStorageModule } = parseMcpEnv(settings.env, settings.labels); + const missing = missingMcpVars(settings.env); + + if (missing.length > 0) { + logger('Error', 'The MCP service is not configured and answers 503 until restart', { + missing, + }); + + return { handler: createUnavailableMcpHandler(gateway.basePath), health: 'degraded' }; + } + + const fileUploads = await loadFileUploads(uploadStorageModule); + const server = new ForestMCPServer({ + ...options, + ...(fileUploads !== undefined && { fileUploads }), + basePath: gateway.basePath, + logger: (level, message) => logger(level, `[MCP] ${message}`), + }); + const baseUrl = new URL(gateway.publicUrl ?? `http://localhost:${gateway.port}`); + + return { + handler: { + matches: makeIsMcpRoute(gateway.basePath), + callback: await server.getHttpCallback(baseUrl), + }, + health: 'ok', + }; +} + +async function buildApiService( + settings: ApiSettings, + gateway: GatewayEnv, + logger: Logger, +): Promise { + const config = parseConfig(settings.env, settings.labels); + const bff = await buildBff({ + config, + logger, + basePath: apiBasePath(gateway.basePath), + gatewayVersion: version, + labels: settings.labels, + name: GATEWAY_API_NAME, + allowedOAuthClients: settings.allowedOAuthClients, + }); + + if (!bff.health().configured.oauth) { + logger('Warn', `API sign-in (${gateway.basePath}/oauth/*?service=api) is off`, { + ...(!config.tokenEncryptionKey && { set: settings.labels.BFF_TOKEN_ENCRYPTION_KEY }), + }); + } + + return { + handler: { + matches: pathname => claimsBffPath(pathname, { docs: config.openapiEnabled }), + callback: bff.callback, + }, + bff, + }; +} + +function apiHealthOf(api: ApiService | undefined): ApiHealth { + if (!api) return 'disabled'; + + const { status, configured } = api.bff.health(); + + return { status, configured }; +} + +function logRoutes( + gateway: GatewayEnv, + mcp: McpService | undefined, + api: ApiService | undefined, + logger: Logger, +): void { + const prefix = gateway.basePath; + + if (mcp) { + logger('Info', `MCP service on ${prefix}/mcp`, { + oauth: `${prefix}/oauth/*`, + discovery: `/.well-known/oauth-authorization-server${prefix}`, + status: mcp.health, + }); + } + + if (api) { + const apiPrefix = apiBasePath(prefix); + + logger('Info', `API service on ${apiPrefix}/agent/*`, { + health: `${apiPrefix}/health`, + docs: `${apiPrefix}/docs`, + oauth: api.bff.health().configured.oauth ? `${prefix}/oauth/*?service=api` : 'API OAuth off', + }); + } + + logger('Info', 'Gateway health on /health'); +} + +export default async function runGateway( + env: NodeJS.ProcessEnv, + logger: Logger = createConsoleLogger(), +): Promise { + const gateway = parseGatewayEnv(env); + + gateway.warnings.forEach(warning => logger('Warn', warning)); + + const mcp = gateway.mcp ? await buildMcpService(gateway.mcp, gateway, logger) : undefined; + const api = gateway.api ? await buildApiService(gateway.api, gateway, logger) : undefined; + + const gatewaySwitch = createGatewaySwitch({ + basePath: gateway.basePath, + mcp: mcp?.handler, + api: api?.handler, + }); + + const callback = createStandaloneHandler({ + version, + gatewaySwitch, + health: () => describeGatewayHealth(version, mcp?.health ?? 'disabled', apiHealthOf(api)), + }); + + const server = new BFFHttpServer({ + port: gateway.port, + logger, + name: GATEWAY_NAME, + callback, + drainActivityLogs: api?.bff.drainActivityLogs, + }); + + await server.start(); + installShutdownHandlers(server, logger, { name: GATEWAY_NAME }); + logRoutes(gateway, mcp, api, logger); + + return server; +} diff --git a/packages/gateway/src/standalone-handler.ts b/packages/gateway/src/standalone-handler.ts new file mode 100644 index 0000000000..efa4ca4df1 --- /dev/null +++ b/packages/gateway/src/standalone-handler.ts @@ -0,0 +1,87 @@ +import type { BffHealth } from '@forestadmin/agent-bff'; +import type { GatewayHandler } from '@forestadmin/mcp-server'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import { makeIsMcpRoute } from '@forestadmin/mcp-server'; + +export const GATEWAY_VERSION_HEADER = 'X-Forest-Gateway-Version'; +export const HEALTH_PATH = '/health'; + +const HTTP_OK = 200; +const HTTP_NOT_FOUND = 404; +const HTTP_SERVICE_UNAVAILABLE = 503; + +export type McpHealth = 'ok' | 'degraded' | 'disabled'; +export type ApiHealth = Pick | 'disabled'; + +export interface GatewayHealth { + healthy: boolean; + version: string; + services: { mcp: McpHealth; api: ApiHealth }; +} + +export interface StandaloneHandlerOptions { + version: string; + gatewaySwitch: GatewayHandler; + health: () => GatewayHealth; +} + +export type StandaloneCallback = (req: IncomingMessage, res: ServerResponse) => void; + +function answerJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); +} + +function isHealthRequest(req: IncomingMessage): boolean { + const [pathname] = (req.url ?? '/').split(/[?#]/, 1); + + return (req.method === 'GET' || req.method === 'HEAD') && pathname === HEALTH_PATH; +} + +export function describeGatewayHealth( + version: string, + mcp: McpHealth, + api: ApiHealth, +): GatewayHealth { + const apiDegraded = api !== 'disabled' && api.status === 'degraded'; + + return { healthy: mcp !== 'degraded' && !apiDegraded, version, services: { mcp, api } }; +} + +export function createUnavailableMcpHandler(basePath: string): GatewayHandler { + return { + matches: makeIsMcpRoute(basePath), + callback: (_req, res) => + answerJson(res, HTTP_SERVICE_UNAVAILABLE, { + error: 'service_unavailable', + error_description: 'The MCP service is not configured on this Gateway.', + }), + }; +} + +export default function createStandaloneHandler({ + version, + gatewaySwitch, + health, +}: StandaloneHandlerOptions): StandaloneCallback { + return (req, res) => { + res.setHeader(GATEWAY_VERSION_HEADER, version); + + if (isHealthRequest(req)) { + const report = health(); + + answerJson(res, report.healthy ? HTTP_OK : HTTP_SERVICE_UNAVAILABLE, report); + + return; + } + + gatewaySwitch.callback(req, res, () => + answerJson(res, HTTP_NOT_FOUND, { + error: 'not_found', + error_description: 'No Gateway service serves this path.', + }), + ); + }; +} diff --git a/packages/gateway/src/version.ts b/packages/gateway/src/version.ts new file mode 100644 index 0000000000..1d6d07396c --- /dev/null +++ b/packages/gateway/src/version.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-dynamic-require, global-require +const { version } = require('../package.json') as { version: string }; + +export default version; diff --git a/packages/gateway/test/cli-dispatch.test.ts b/packages/gateway/test/cli-dispatch.test.ts new file mode 100644 index 0000000000..18301bcc7c --- /dev/null +++ b/packages/gateway/test/cli-dispatch.test.ts @@ -0,0 +1,108 @@ +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import os from 'os'; +import path from 'path'; + +import dispatchCli, { USAGE } from '../src/cli-dispatch'; +import version from '../src/version'; + +const noopLogger = () => undefined; + +describe('dispatchCli', () => { + let stdout: jest.SpyInstance; + let stderr: jest.SpyInstance; + + beforeEach(() => { + stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function writtenDocument(): { servers: { url: string }[] } { + return JSON.parse(stdout.mock.calls.map(([chunk]) => chunk).join('')); + } + + it.each([ + [{}, '/api'], + [{ FOREST_GATEWAY_BASE_PATH: '/ai' }, '/ai/api'], + [ + { FOREST_GATEWAY_API_PUBLIC_URL: 'https://gateway.example.com/api' }, + 'https://gateway.example.com/api', + ], + [ + { + FOREST_GATEWAY_API_PUBLIC_URL: 'https://corp.example/forest/api', + FOREST_GATEWAY_BASE_PATH: '/forest', + }, + 'https://corp.example/forest/api', + ], + [{ BFF_PUBLIC_URL: 'https://corp.example/api' }, 'https://corp.example/api'], + [{ FOREST_GATEWAY_URL: 'https://gateway.example.com' }, '/api'], + ])('should announce the API server of %p as %s', async (env, url) => { + const outcome = await dispatchCli(['openapi'], env, noopLogger); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(writtenDocument().servers[0].url).toBe(url); + }); + + it('should warn naming a legacy public url it reads', async () => { + const logger = jest.fn(); + + await dispatchCli(['openapi'], { BFF_PUBLIC_URL: 'https://corp.example/api' }, logger); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + 'BFF_PUBLIC_URL is a legacy name: use FOREST_GATEWAY_API_PUBLIC_URL instead', + ); + }); + + it('should refuse a legacy root public url, asking for /api', async () => { + await expect( + dispatchCli(['openapi'], { BFF_PUBLIC_URL: 'https://corp.example' }, noopLogger), + ).rejects.toThrow('BFF_PUBLIC_URL must end with /api'); + }); + + it('should write the document to the --output file', async () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'gateway-openapi-')); + const file = path.join(directory, 'doc.json'); + + try { + const outcome = await dispatchCli(['openapi', '--output', file], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(JSON.parse(readFileSync(file, 'utf8')).servers[0].url).toBe('/api'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('should print the usage on --help', async () => { + expect(await dispatchCli(['--help'], {})).toEqual({ exitCode: 0 }); + expect(stdout).toHaveBeenCalledWith(`${USAGE}\n`); + }); + + it('should print the version on --version', async () => { + expect(await dispatchCli(['--version'], {})).toEqual({ exitCode: 0 }); + expect(stdout).toHaveBeenCalledWith(`${version}\n`); + }); + + it('should reject an unknown command', async () => { + expect(await dispatchCli(['serve'], {})).toEqual({ exitCode: 1 }); + expect(stderr).toHaveBeenCalledWith( + "Unknown command: serve\nRun 'forest-gateway --help' for usage.\n", + ); + }); + + it('should reject extra openapi arguments', async () => { + expect(await dispatchCli(['openapi', 'extra'], {})).toEqual({ exitCode: 1 }); + expect(stderr).toHaveBeenCalledWith( + 'openapi accepts only --output, got: "extra"\nRun \'forest-gateway --help\' for usage.\n', + ); + }); + + it('should refuse to start without FOREST_GATEWAY_SERVICES', async () => { + await expect(dispatchCli([], {})).rejects.toThrow(/FOREST_GATEWAY_SERVICES.*"mcp", "api"/); + }); +}); diff --git a/packages/gateway/test/env-aliases.test.ts b/packages/gateway/test/env-aliases.test.ts new file mode 100644 index 0000000000..32e27dfd19 --- /dev/null +++ b/packages/gateway/test/env-aliases.test.ts @@ -0,0 +1,115 @@ +import EnvAliases from '../src/env-aliases'; + +describe('EnvAliases', () => { + describe('resolve', () => { + it('should read the new name without warning', () => { + const aliases = new EnvAliases({ PORT: '8080' }); + + expect(aliases.resolve('PORT', ['HTTP_PORT'])).toEqual({ key: 'PORT', value: '8080' }); + expect(aliases.warnings).toEqual([]); + }); + + it('should fall back to the new name as key when nothing is set', () => { + const aliases = new EnvAliases({}); + + expect(aliases.resolve('PORT', ['HTTP_PORT'])).toEqual({ key: 'PORT' }); + expect(aliases.warnings).toEqual([]); + }); + + it('should read an alias and warn naming both keys', () => { + const aliases = new EnvAliases({ HTTP_PORT: '8080' }); + + expect(aliases.resolve('PORT', ['HTTP_PORT'])).toEqual({ key: 'HTTP_PORT', value: '8080' }); + expect(aliases.warnings).toEqual(['HTTP_PORT is a legacy name: use PORT instead']); + }); + + it('should let a non-empty new name win, false included', () => { + const aliases = new EnvAliases({ NEW: 'false', OLD: 'true' }); + + expect(aliases.resolve('NEW', ['OLD'])).toEqual({ key: 'NEW', value: 'false' }); + }); + + it('should warn about a shadowed alias without validating it', () => { + const aliases = new EnvAliases({ PORT: '8080', HTTP_PORT: 'abc', MCP_SERVER_PORT: 'abc' }); + + expect(aliases.resolve('PORT', ['MCP_SERVER_PORT', 'HTTP_PORT'])).toEqual({ + key: 'PORT', + value: '8080', + }); + expect(aliases.warnings).toEqual([ + 'MCP_SERVER_PORT is ignored: PORT is set', + 'HTTP_PORT is ignored: PORT is set', + ]); + }); + + it.each(['', ' '])('should treat a new name set to %p as unset', empty => { + const aliases = new EnvAliases({ NEW: empty, OLD: 'value' }); + + expect(aliases.resolve('NEW', ['OLD'])).toEqual({ key: 'OLD', value: 'value' }); + }); + + it('should ignore an empty alias', () => { + const aliases = new EnvAliases({ OLD: '' }); + + expect(aliases.resolve('NEW', ['OLD'])).toEqual({ key: 'NEW' }); + expect(aliases.warnings).toEqual([]); + }); + + it('should keep a blank value when asked to', () => { + const aliases = new EnvAliases({ NEW: ' ', OLD: 'value' }); + + expect(aliases.resolve('NEW', ['OLD'], { blankIsSet: true })).toEqual({ + key: 'NEW', + value: ' ', + }); + }); + + it('should still treat an empty value as unset when blanks are kept', () => { + const aliases = new EnvAliases({ NEW: '', OLD: ' ' }); + + expect(aliases.resolve('NEW', ['OLD'], { blankIsSet: true })).toEqual({ + key: 'OLD', + value: ' ', + }); + }); + + it('should fail naming two aliases that disagree', () => { + const aliases = new EnvAliases({ HTTP_PORT: '8080', MCP_SERVER_PORT: '3931' }); + + expect(() => aliases.resolve('PORT', ['MCP_SERVER_PORT', 'HTTP_PORT'])).toThrow( + 'MCP_SERVER_PORT and HTTP_PORT disagree: set PORT to the value the Gateway should use.', + ); + }); + + it('should accept two aliases that agree, warning about each', () => { + const aliases = new EnvAliases({ HTTP_PORT: '8080', MCP_SERVER_PORT: ' 8080 ' }); + + expect(aliases.resolve('PORT', ['MCP_SERVER_PORT', 'HTTP_PORT'])).toEqual({ + key: 'MCP_SERVER_PORT', + value: ' 8080 ', + }); + expect(aliases.warnings).toEqual([ + 'MCP_SERVER_PORT is a legacy name: use PORT instead', + 'HTTP_PORT is a legacy name: use PORT instead', + ]); + }); + + it('should never put a value in a warning', () => { + const aliases = new EnvAliases({ OLD_SECRET: 'top-secret', NEW_SECRET: 'other-secret' }); + + aliases.resolve('NEW_SECRET', ['OLD_SECRET']); + + expect(aliases.warnings.join('\n')).not.toMatch(/top-secret|other-secret/); + }); + }); + + describe('ignore', () => { + it('should warn about each set key with the reason', () => { + const aliases = new EnvAliases({ A: 'x', B: '', C: 'y' }); + + aliases.ignore(['A', 'B', 'C'], 'not read'); + + expect(aliases.warnings).toEqual(['A is ignored: not read', 'C is ignored: not read']); + }); + }); +}); diff --git a/packages/gateway/test/gateway-env.test.ts b/packages/gateway/test/gateway-env.test.ts new file mode 100644 index 0000000000..477c3c96d4 --- /dev/null +++ b/packages/gateway/test/gateway-env.test.ts @@ -0,0 +1,544 @@ +import parseGatewayEnv, { + parseOpenApiEnv, + parseServices, + resolveListenPort, +} from '../src/gateway-env'; + +describe('parseServices', () => { + it.each([ + ['mcp', ['mcp']], + ['api', ['api']], + ['mcp,api', ['mcp', 'api']], + [' api , mcp ', ['api', 'mcp']], + ['mcp,mcp', ['mcp']], + ])('should read "%s"', (raw, expected) => { + expect([...parseServices(raw)]).toEqual(expected); + }); + + it.each([undefined, '', ' , ', 'mcp,foo', 'MCP'])( + 'should reject %p listing the known services', + raw => { + expect(() => parseServices(raw)).toThrow(/FOREST_GATEWAY_SERVICES.*"mcp", "api"/); + }, + ); +}); + +describe('parseGatewayEnv', () => { + it.each([ + ['mcp', 3931], + ['mcp,api', 3931], + ['api', 3450], + ])('should default the port of "%s" to %d', (services, port) => { + expect(parseGatewayEnv({ FOREST_GATEWAY_SERVICES: services }).port).toBe(port); + }); + + it('should read PORT', () => { + expect(parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', PORT: '8080' }).port).toBe(8080); + }); + + it.each(['-1', '65536', '80a', '1.5'])('should reject PORT=%s', port => { + expect(() => parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', PORT: port })).toThrow( + `Invalid PORT "${port}"`, + ); + }); + + it.each(['mcp', 'mcp,api'])( + 'should refuse PORT=0 without FOREST_GATEWAY_URL when serving "%s", naming both', + services => { + expect(() => parseGatewayEnv({ FOREST_GATEWAY_SERVICES: services, PORT: '0' })).toThrow( + /PORT=0.*FOREST_GATEWAY_URL/s, + ); + }, + ); + + it('should accept PORT=0 with FOREST_GATEWAY_URL', () => { + expect( + parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp', + PORT: '0', + FOREST_GATEWAY_URL: 'https://gateway.example.com', + }), + ).toEqual(expect.objectContaining({ port: 0, publicUrl: 'https://gateway.example.com' })); + }); + + it('should accept PORT=0 for the API alone', () => { + expect(parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', PORT: '0' }).port).toBe(0); + }); + + it.each([ + 'gateway.example.com', + 'ftp://gateway.example.com', + 'https://gateway.example.com/ai', + 'https://gateway.example.com?x=1', + 'https://user:pass@gateway.example.com', + ])('should reject FOREST_GATEWAY_URL=%s', url => { + expect(() => + parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'mcp', FOREST_GATEWAY_URL: url }), + ).toThrow('Invalid FOREST_GATEWAY_URL'); + }); + + it.each([ + [undefined, ''], + ['/', ''], + ['ai', '/ai'], + ['/ai/', '/ai'], + ])('should normalize FOREST_GATEWAY_BASE_PATH=%p to "%s"', (raw, basePath) => { + expect( + parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', FOREST_GATEWAY_BASE_PATH: raw }).basePath, + ).toBe(basePath); + }); + + it('should reject a base path that is not a plain prefix', () => { + expect(() => + parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', FOREST_GATEWAY_BASE_PATH: '/a b' }), + ).toThrow('Invalid FOREST_GATEWAY_BASE_PATH'); + }); +}); + +const SECRET = 'do-not-print-me'; + +describe('parseGatewayEnv aliases', () => { + describe('port', () => { + it.each(['MCP_SERVER_PORT', 'HTTP_PORT'])('should read %s with one warning', key => { + const gateway = parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', [key]: '8080' }); + + expect(gateway.port).toBe(8080); + expect(gateway.warnings).toEqual([`${key} is a legacy name: use PORT instead`]); + }); + + it('should fail naming both legacy ports when they disagree', () => { + expect(() => + parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + HTTP_PORT: '8080', + MCP_SERVER_PORT: '3931', + }), + ).toThrow(/MCP_SERVER_PORT and HTTP_PORT disagree/); + }); + + it('should listen on PORT and warn about each shadowed legacy port', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + PORT: '8080', + HTTP_PORT: 'abc', + MCP_SERVER_PORT: 'abc', + }); + + expect(gateway.port).toBe(8080); + expect(gateway.warnings).toEqual([ + 'MCP_SERVER_PORT is ignored: PORT is set', + 'HTTP_PORT is ignored: PORT is set', + ]); + }); + + it('should name the legacy key of an invalid port', () => { + expect(() => parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', HTTP_PORT: 'abc' })).toThrow( + 'Invalid HTTP_PORT "abc"', + ); + }); + + it('should never hand a port to the parsers', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp,api', + PORT: '8080', + HTTP_PORT: '8080', + MCP_SERVER_PORT: '8080', + }); + + expect(gateway.api?.env.HTTP_PORT).toBeUndefined(); + expect(gateway.mcp?.env.MCP_SERVER_PORT).toBeUndefined(); + }); + }); + + describe('gateway url', () => { + it('should let FOREST_MCP_SERVER_URL win over an empty FOREST_GATEWAY_URL', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp', + FOREST_GATEWAY_URL: '', + FOREST_MCP_SERVER_URL: 'https://mcp.example.com', + }); + + expect(gateway.publicUrl).toBe('https://mcp.example.com'); + expect(gateway.warnings).toEqual([ + 'FOREST_MCP_SERVER_URL is a legacy name: use FOREST_GATEWAY_URL instead', + ]); + }); + + it('should name FOREST_MCP_SERVER_URL when it is invalid', () => { + expect(() => + parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp', + FOREST_MCP_SERVER_URL: 'https://mcp.example.com/path', + }), + ).toThrow('Invalid FOREST_MCP_SERVER_URL'); + }); + + it('should warn naming FOREST_GATEWAY_URL when the MCP has none', () => { + const gateway = parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'mcp', PORT: '4000' }); + + expect(gateway.warnings).toEqual([ + 'FOREST_GATEWAY_URL is not set: MCP clients are told http://localhost:4000', + ]); + }); + + it.each(['FOREST_GATEWAY_URL', 'FOREST_MCP_SERVER_URL'])( + 'should ignore %s with a warning when only the API is on', + key => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + [key]: 'not a url', + }); + + expect(gateway.publicUrl).toBeUndefined(); + expect(gateway.warnings).toEqual([ + `${key} is ignored: the mcp service is off: the API reads FOREST_GATEWAY_API_PUBLIC_URL`, + ]); + }, + ); + + it('should not hand the gateway url to the API', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp,api', + FOREST_GATEWAY_URL: 'https://gateway.example.com', + }); + + expect(gateway.api?.env.BFF_PUBLIC_URL).toBeUndefined(); + }); + }); + + describe('agent url', () => { + it('should feed FOREST_AGENT_URL to both services', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp,api', + FOREST_AGENT_URL: 'https://agent.example.com', + }); + + expect(gateway.api?.env.AGENT_URL).toBe('https://agent.example.com'); + expect(gateway.mcp?.env.FOREST_AGENT_URL).toBe('https://agent.example.com'); + }); + + it('should feed AGENT_URL to both services when it is the only one set', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp,api', + AGENT_URL: 'https://agent.example.com', + }); + + expect(gateway.api?.env.AGENT_URL).toBe('https://agent.example.com'); + expect(gateway.mcp?.env.FOREST_AGENT_URL).toBe('https://agent.example.com'); + expect(gateway.api?.labels.AGENT_URL).toBe('AGENT_URL'); + expect(gateway.mcp?.labels.agentUrl).toBe('AGENT_URL'); + }); + + it('should prefer FOREST_AGENT_URL over a different AGENT_URL, naming AGENT_URL once', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp,api', + FOREST_GATEWAY_URL: 'https://gateway.example.com', + FOREST_AGENT_URL: 'https://agent.example.com', + AGENT_URL: 'https://other.example.com', + }); + + expect(gateway.api?.env.AGENT_URL).toBe('https://agent.example.com'); + expect(gateway.mcp?.env.FOREST_AGENT_URL).toBe('https://agent.example.com'); + expect(gateway.warnings).toEqual(['AGENT_URL is ignored: FOREST_AGENT_URL is set']); + }); + + it('should ignore AGENT_URL with a warning when the API is off', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp', + FOREST_GATEWAY_URL: 'https://gateway.example.com', + AGENT_URL: 'https://agent.example.com', + }); + + expect(gateway.mcp?.env.FOREST_AGENT_URL).toBeUndefined(); + expect(gateway.warnings).toEqual([ + 'AGENT_URL is ignored: the api service is off: use FOREST_AGENT_URL', + ]); + }); + + it.each([ + ['a query string', `https://agent.example.com?token=${SECRET}`], + ['a fragment', `https://agent.example.com#${SECRET}`], + ['no scheme', `agent.example.com/${SECRET}`], + ['a non-http scheme', `ftp://agent.example.com/${SECRET}`], + ])('should fail on an agent url with %s, naming the key without its value', (_, url) => { + const parse = () => + parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'mcp,api', FOREST_AGENT_URL: url }); + + expect(parse).toThrow( + 'Invalid FOREST_AGENT_URL: expected an absolute http(s) URL with no query string or fragment.', + ); + expect(parse).not.toThrow(SECRET); + }); + }); + + describe('API variables', () => { + it.each([ + ['FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY', 'BFF_TOKEN_ENCRYPTION_KEY'], + ['FOREST_GATEWAY_API_ALLOWED_ORIGINS', 'BFF_ALLOWED_ORIGINS'], + ['FOREST_GATEWAY_API_DEFAULT_TIMEZONE', 'BFF_DEFAULT_TIMEZONE'], + ['FOREST_GATEWAY_API_AGENT_TIMEOUT_MS', 'BFF_AGENT_TIMEOUT_MS'], + ['FOREST_GATEWAY_API_AI_TIMEOUT_MS', 'BFF_AI_TIMEOUT_MS'], + ['FOREST_GATEWAY_API_OPENAPI_ENABLED', 'BFF_OPENAPI_ENABLED'], + ['FOREST_GATEWAY_API_RATE_LIMIT_MAX_REQUESTS', 'BFF_RATE_LIMIT_MAX_REQUESTS'], + ['FOREST_GATEWAY_API_RATE_LIMIT_WINDOW_MS', 'BFF_RATE_LIMIT_WINDOW_MS'], + ['FOREST_GATEWAY_API_PUBLIC_URL', 'BFF_PUBLIC_URL'], + ])('should hand %s to the API as %s, labelled by its own name', (name, bffKey) => { + const value = bffKey === 'BFF_PUBLIC_URL' ? 'https://corp.example/api' : 'x'; + const gateway = parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', [name]: value }); + + expect(gateway.api?.env[bffKey]).toBe(value); + expect(gateway.api?.labels[bffKey]).toBe(name); + expect(gateway.warnings).toEqual([]); + }); + + it('should read a BFF_ alias, labelled by its legacy name, with one warning', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + BFF_TOKEN_ENCRYPTION_KEY: SECRET, + }); + + expect(gateway.api?.env.BFF_TOKEN_ENCRYPTION_KEY).toBe(SECRET); + expect(gateway.api?.labels.BFF_TOKEN_ENCRYPTION_KEY).toBe('BFF_TOKEN_ENCRYPTION_KEY'); + expect(gateway.warnings).toEqual([ + 'BFF_TOKEN_ENCRYPTION_KEY is a legacy name: use FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY instead', + ]); + }); + + it('should label an unset key by its new name', () => { + const gateway = parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api' }); + + expect(gateway.api?.labels).toEqual( + expect.objectContaining({ + AGENT_URL: 'FOREST_AGENT_URL', + BFF_ALLOWED_ORIGINS: 'FOREST_GATEWAY_API_ALLOWED_ORIGINS', + BFF_TOKEN_ENCRYPTION_KEY: 'FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY', + }), + ); + }); + + it('should not resolve API variables when the API is off', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp', + FOREST_GATEWAY_URL: 'https://gateway.example.com', + BFF_PUBLIC_URL: 'https://corp.example', + }); + + expect(gateway.api).toBeUndefined(); + expect(gateway.warnings).toEqual([]); + }); + }); + + describe('API public url', () => { + it.each([ + ['', 'https://corp.example/api'], + ['', 'https://corp.example/proxy/api/'], + ['/forest', 'https://corp.example/forest/api'], + ])('should accept under base path "%s" the url %s', (basePath, url) => { + expect(() => + parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + FOREST_GATEWAY_BASE_PATH: basePath, + FOREST_GATEWAY_API_PUBLIC_URL: url, + }), + ).not.toThrow(); + }); + + it.each([ + ['BFF_PUBLIC_URL', '', 'https://corp.example', '/api'], + ['FOREST_GATEWAY_API_PUBLIC_URL', '/forest', 'https://corp.example/api', '/forest/api'], + ['FOREST_GATEWAY_API_PUBLIC_URL', '', 'https://corp.example/apis', '/api'], + ])('should fail when %s under "%s" is %s, asking for %s', (key, basePath, url, suffix) => { + expect(() => + parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + FOREST_GATEWAY_BASE_PATH: basePath, + [key]: url, + }), + ).toThrow( + `Invalid configuration: ${key} must end with ${suffix}, where the Gateway serves the API.`, + ); + }); + + it('should name BFF_PUBLIC_URL when it is malformed', () => { + expect(() => + parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', BFF_PUBLIC_URL: 'corp.example/api' }), + ).toThrow('Invalid configuration: BFF_PUBLIC_URL must be a valid http(s) URL.'); + }); + + it('should not validate a root BFF_PUBLIC_URL shadowed by the new name', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + FOREST_GATEWAY_API_PUBLIC_URL: 'https://corp.example/api', + BFF_PUBLIC_URL: 'https://corp.example', + }); + + expect(gateway.api?.env.BFF_PUBLIC_URL).toBe('https://corp.example/api'); + expect(gateway.warnings).toEqual([ + 'BFF_PUBLIC_URL is ignored: FOREST_GATEWAY_API_PUBLIC_URL is set', + ]); + }); + }); + + describe('allowed OAuth clients', () => { + it('should hand the legacy MCP allowlist to both services with one warning', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp,api', + FOREST_GATEWAY_URL: 'https://gateway.example.com', + FOREST_MCP_ALLOWED_OAUTH_CLIENTS: 'claude.ai, Dust.tt', + }); + + expect(gateway.api?.allowedOAuthClients).toEqual(['claude.ai', 'dust.tt']); + expect(gateway.mcp?.env.FOREST_MCP_ALLOWED_OAUTH_CLIENTS).toBe('claude.ai, Dust.tt'); + expect(gateway.warnings).toEqual([ + 'FOREST_MCP_ALLOWED_OAUTH_CLIENTS is a legacy name: use FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS instead', + ]); + }); + + it('should let the new name win in both services', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp,api', + FOREST_GATEWAY_URL: 'https://gateway.example.com', + FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS: 'claude.ai', + FOREST_MCP_ALLOWED_OAUTH_CLIENTS: 'dust.tt', + }); + + expect(gateway.api?.allowedOAuthClients).toEqual(['claude.ai']); + expect(gateway.mcp?.env.FOREST_MCP_ALLOWED_OAUTH_CLIENTS).toBe('claude.ai'); + }); + + it.each([ + ['FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS', 'https://claude.ai', /entry "https:\/\/claude.ai"/], + ['FOREST_MCP_ALLOWED_OAUTH_CLIENTS', ' , ', /no domains to allow/], + ])('should fail naming %s when its list is invalid', (key, value, reason) => { + const parse = () => parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', [key]: value }); + + expect(parse).toThrow(`Invalid ${key}`); + expect(parse).toThrow(reason); + }); + + it.each([ + ['FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS', {}], + ['FOREST_MCP_ALLOWED_OAUTH_CLIENTS', {}], + ['FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS', { FOREST_MCP_ALLOWED_OAUTH_CLIENTS: 'claude.ai' }], + ])('should fail closed on a whitespace-only %s', (key, others) => { + expect(() => + parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api', [key]: ' ', ...others }), + ).toThrow(`Invalid ${key}: no domains to allow.`); + }); + + it('should leave the allowlist unset on an empty value', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + FOREST_GATEWAY_ALLOWED_OAUTH_CLIENTS: '', + }); + + expect(gateway.api?.allowedOAuthClients).toBeUndefined(); + }); + + it('should leave the allowlist unset when no name is set', () => { + const gateway = parseGatewayEnv({ FOREST_GATEWAY_SERVICES: 'api' }); + + expect(gateway.api?.allowedOAuthClients).toBeUndefined(); + }); + }); + + describe('MCP labels', () => { + it('should name the MCP token TTLs by their env keys', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'mcp', + FOREST_GATEWAY_URL: 'https://gateway.example.com', + }); + + expect(gateway.mcp?.labels).toEqual({ + accessTokenTtl: 'FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS', + refreshTokenTtl: 'FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS', + agentUrl: 'FOREST_AGENT_URL', + }); + }); + }); + + it('should take a forest-bff configuration with one warning per alias', () => { + const gateway = parseGatewayEnv({ + FOREST_GATEWAY_SERVICES: 'api', + AGENT_URL: 'https://agent.example.com', + HTTP_PORT: '8080', + BFF_PUBLIC_URL: 'https://corp.example/api', + BFF_TOKEN_ENCRYPTION_KEY: SECRET, + BFF_ALLOWED_ORIGINS: 'https://app.example.com', + }); + + expect(gateway.port).toBe(8080); + expect(gateway.warnings).toEqual([ + 'HTTP_PORT is a legacy name: use PORT instead', + 'AGENT_URL is a legacy name: use FOREST_AGENT_URL instead', + 'BFF_PUBLIC_URL is a legacy name: use FOREST_GATEWAY_API_PUBLIC_URL instead', + 'BFF_TOKEN_ENCRYPTION_KEY is a legacy name: use FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY instead', + 'BFF_ALLOWED_ORIGINS is a legacy name: use FOREST_GATEWAY_API_ALLOWED_ORIGINS instead', + ]); + expect(gateway.warnings.join('\n')).not.toContain(SECRET); + }); +}); + +describe('parseOpenApiEnv', () => { + it('should resolve the API variables without FOREST_GATEWAY_SERVICES', () => { + const { basePath, api, warnings } = parseOpenApiEnv({ + FOREST_GATEWAY_BASE_PATH: '/ai', + AGENT_URL: 'https://agent.example.com', + FOREST_GATEWAY_API_PUBLIC_URL: 'https://corp.example/ai/api', + }); + + expect(basePath).toBe('/ai'); + expect(api.env).toEqual( + expect.objectContaining({ + AGENT_URL: 'https://agent.example.com', + BFF_PUBLIC_URL: 'https://corp.example/ai/api', + }), + ); + expect(api.labels.BFF_PUBLIC_URL).toBe('FOREST_GATEWAY_API_PUBLIC_URL'); + expect(warnings).toEqual(['AGENT_URL is a legacy name: use FOREST_AGENT_URL instead']); + }); + + it('should refuse an API public url outside the base path', () => { + expect(() => + parseOpenApiEnv({ + FOREST_GATEWAY_BASE_PATH: '/ai', + FOREST_GATEWAY_API_PUBLIC_URL: 'https://corp.example/api', + }), + ).toThrow('FOREST_GATEWAY_API_PUBLIC_URL must end with /ai/api'); + }); +}); + +describe('resolveListenPort', () => { + it.each([ + ['mcp', 3931], + ['mcp,api', 3931], + ['api', 3450], + ])('should default the port of "%s" to %d', (services, port) => { + expect(resolveListenPort({ FOREST_GATEWAY_SERVICES: services })).toBe(port); + }); + + it.each([ + [{ PORT: '8080', HTTP_PORT: '9090' }, 8080], + [{ MCP_SERVER_PORT: ' 8081 ' }, 8081], + [{ HTTP_PORT: '8082' }, 8082], + ])('should agree with parseGatewayEnv on %p', (env, port) => { + const full = { FOREST_GATEWAY_SERVICES: 'mcp,api', ...env }; + + expect(resolveListenPort(full)).toBe(port); + expect(parseGatewayEnv(full).port).toBe(port); + }); + + it('should fail naming both legacy ports when they disagree', () => { + expect(() => + resolveListenPort({ + FOREST_GATEWAY_SERVICES: 'api', + MCP_SERVER_PORT: '3931', + HTTP_PORT: '8080', + }), + ).toThrow('MCP_SERVER_PORT and HTTP_PORT disagree'); + }); + + it('should reject invalid services', () => { + expect(() => resolveListenPort({})).toThrow(/FOREST_GATEWAY_SERVICES/); + }); +}); diff --git a/packages/gateway/test/healthcheck.test.ts b/packages/gateway/test/healthcheck.test.ts new file mode 100644 index 0000000000..34c0546a88 --- /dev/null +++ b/packages/gateway/test/healthcheck.test.ts @@ -0,0 +1,111 @@ +import type { AddressInfo } from 'net'; + +import http from 'http'; + +import probeHealth from '../src/healthcheck'; + +async function serve( + status: number, +): Promise<{ server: http.Server; port: number; paths: string[] }> { + const paths: string[] = []; + const server = http.createServer((req, res) => { + paths.push(req.url ?? ''); + res.statusCode = status; + res.end(); + }); + + await new Promise(resolve => { + server.listen(0, resolve); + }); + + return { server, port: (server.address() as AddressInfo).port, paths }; +} + +function close(server: http.Server): Promise { + return new Promise(resolve => { + server.close(() => resolve()); + }); +} + +describe('probeHealth', () => { + it('should pass when /health on PORT answers 200', async () => { + const { server, port, paths } = await serve(200); + + try { + await expect( + probeHealth({ FOREST_GATEWAY_SERVICES: 'mcp,api', PORT: String(port) }), + ).resolves.toBeUndefined(); + expect(paths).toEqual(['/health']); + } finally { + await close(server); + } + }); + + it('should probe the port a legacy alias names', async () => { + const { server, port } = await serve(200); + + try { + await expect( + probeHealth({ FOREST_GATEWAY_SERVICES: 'api', HTTP_PORT: String(port) }), + ).resolves.toBeUndefined(); + } finally { + await close(server); + } + }); + + it('should fail when /health answers 503', async () => { + const { server, port } = await serve(503); + + try { + await expect( + probeHealth({ FOREST_GATEWAY_SERVICES: 'mcp', PORT: String(port) }), + ).resolves.toBe(`/health on port ${port} answered 503`); + } finally { + await close(server); + } + }); + + it('should fail when nothing listens on the port', async () => { + const { server, port } = await serve(200); + await close(server); + + await expect( + probeHealth({ FOREST_GATEWAY_SERVICES: 'mcp', PORT: String(port) }), + ).resolves.toMatch(new RegExp(`^/health on port ${port}: ECONNREFUSED$`)); + }); + + it('should fail when /health does not answer within the timeout', async () => { + const server = http.createServer(() => undefined); + await new Promise(resolve => { + server.listen(0, resolve); + }); + const { port } = server.address() as AddressInfo; + + try { + await expect( + probeHealth({ FOREST_GATEWAY_SERVICES: 'mcp', PORT: String(port) }, 50), + ).resolves.toBe(`/health on port ${port}: timed out after 50ms`); + } finally { + server.closeAllConnections(); + await close(server); + } + }); + + it('should fail naming the variable when the services are invalid', async () => { + await expect(probeHealth({ FOREST_GATEWAY_SERVICES: 'foo' })).resolves.toMatch( + /^Invalid FOREST_GATEWAY_SERVICES "foo"/, + ); + }); + + it('should fail naming the variable when the port is invalid', async () => { + await expect(probeHealth({ FOREST_GATEWAY_SERVICES: 'mcp', PORT: 'abc' })).resolves.toBe( + 'Invalid PORT "abc": expected an integer between 0 and 65535.', + ); + }); + + it('should fail on PORT=0, which cannot be probed', async () => { + await expect(probeHealth({ FOREST_GATEWAY_SERVICES: 'mcp', PORT: '0' })).resolves.toBe( + 'PORT=0 binds a port chosen by the OS, which cannot be probed', + ); + }); +}); diff --git a/packages/gateway/test/helpers/fake-forest-server.ts b/packages/gateway/test/helpers/fake-forest-server.ts new file mode 100644 index 0000000000..9c44610f77 --- /dev/null +++ b/packages/gateway/test/helpers/fake-forest-server.ts @@ -0,0 +1,43 @@ +import type { AddressInfo } from 'net'; + +import http from 'http'; + +const ROUTES: Record = { + '/liana/environment': { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }, + '/liana/forest-schema': { + data: [], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }, +}; + +export interface FakeForestServer { + url: string; + close: () => Promise; +} + +export default async function startFakeForestServer(): Promise { + const server = http.createServer((req, res) => { + const [pathname] = (req.url ?? '/').split('?', 1); + const body = ROUTES[pathname]; + + res.setHeader('Content-Type', 'application/json'); + res.statusCode = body ? 200 : 404; + res.end(JSON.stringify(body ?? { error: 'Not found' })); + }); + + await new Promise(resolve => { + server.listen(0, '127.0.0.1', resolve); + }); + + const { port } = server.address() as AddressInfo; + + return { + url: `http://127.0.0.1:${port}`, + close: () => + new Promise(resolve => { + server.close(() => resolve()); + }), + }; +} diff --git a/packages/gateway/test/helpers/gateway-env.ts b/packages/gateway/test/helpers/gateway-env.ts new file mode 100644 index 0000000000..10cd85024b --- /dev/null +++ b/packages/gateway/test/helpers/gateway-env.ts @@ -0,0 +1,38 @@ +import type { AddressInfo } from 'net'; + +import net from 'net'; + +export const AUTH_SECRET = 'auth-secret'; +export const ALLOWED_ORIGIN = 'https://app.example.com'; +export const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64'); + +export function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + + probe.listen(0, () => { + const { port } = probe.address() as AddressInfo; + probe.close(() => resolve(port)); + }); + probe.on('error', reject); + }); +} + +export function gatewayEnv( + forestServerUrl: string, + port: number, + overrides: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + return { + FOREST_GATEWAY_SERVICES: 'mcp,api', + PORT: String(port), + FOREST_ENV_SECRET: 'env-secret', + FOREST_AUTH_SECRET: AUTH_SECRET, + FOREST_SERVER_URL: forestServerUrl, + FOREST_APP_URL: 'https://app.forestadmin.com', + FOREST_AGENT_URL: 'https://agent.example.com', + FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY: ENCRYPTION_KEY, + FOREST_GATEWAY_API_ALLOWED_ORIGINS: ALLOWED_ORIGIN, + ...overrides, + }; +} diff --git a/packages/gateway/test/run-gateway.test.ts b/packages/gateway/test/run-gateway.test.ts new file mode 100644 index 0000000000..b2da0926ca --- /dev/null +++ b/packages/gateway/test/run-gateway.test.ts @@ -0,0 +1,589 @@ +import type { FakeForestServer } from './helpers/fake-forest-server'; +import type { BFFHttpServer, Logger } from '@forestadmin/agent-bff'; + +import request from 'supertest'; + +import { runGateway, version } from '../src'; +import startFakeForestServer from './helpers/fake-forest-server'; +import { + ALLOWED_ORIGIN, + ENCRYPTION_KEY, + gatewayEnv, + getAvailablePort, +} from './helpers/gateway-env'; + +const SIGNALS: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; + +interface RunningGateway { + port: number; + url: string; + server: BFFHttpServer; + logger: jest.Mock; +} + +let forest: FakeForestServer; +let listenersBefore: Map void>>; + +async function startGateway(overrides: NodeJS.ProcessEnv = {}): Promise { + const port = await getAvailablePort(); + const logger = jest.fn(); + const server = await runGateway( + gatewayEnv(forest.url, port, overrides), + logger as unknown as Logger, + ); + + return { port, url: `http://127.0.0.1:${port}`, server, logger }; +} + +beforeAll(async () => { + forest = await startFakeForestServer(); + listenersBefore = new Map( + SIGNALS.map(signal => [ + signal, + new Set(process.listeners(signal) as ((...args: unknown[]) => void)[]), + ]), + ); +}); + +afterAll(async () => { + for (const signal of SIGNALS) { + for (const listener of process.listeners(signal)) { + if (!listenersBefore.get(signal)?.has(listener as (...args: unknown[]) => void)) { + process.removeListener(signal, listener); + } + } + } + + await forest.close(); +}); + +describe.each(['', '/ai'])('both services under base path "%s"', P => { + let gateway: RunningGateway; + + beforeAll(async () => { + gateway = await startGateway({ FOREST_GATEWAY_BASE_PATH: P }); + }); + + afterAll(async () => { + await gateway.server.stop(); + }); + + it('should serve the MCP endpoint', async () => { + const response = await request(gateway.url).post(`${P}/mcp`).send({}); + + expect(response.status).toBe(401); + expect(response.headers['x-powered-by']).toBe('Express'); + }); + + it('should serve the API agent routes under /api', async () => { + const response = await request(gateway.url).get(`${P}/api/agent/v1/books`); + + expect(response.status).toBe(401); + expect(response.headers['x-forest-bff-version']).toBeDefined(); + }); + + it('should let the MCP serve the token endpoint when no service is given', async () => { + const response = await request(gateway.url) + .post(`${P}/oauth/token`) + .type('form') + .send({ grant_type: 'authorization_code' }); + + expect(response.status).toBe(400); + expect(response.headers['x-powered-by']).toBe('Express'); + expect(response.headers['x-forest-bff-version']).toBeUndefined(); + expect(response.body.error).toBe('invalid_request'); + }); + + it('should let the API serve the token endpoint with service=api', async () => { + const response = await request(gateway.url) + .post(`${P}/oauth/token?service=api`) + .type('form') + .send({ grant_type: 'authorization_code' }); + + expect(response.status).toBe(400); + expect(response.headers['x-forest-bff-version']).toBeDefined(); + }); + + it("should answer the API's CORS preflight on its token endpoint", async () => { + const response = await request(gateway.url) + .options(`${P}/oauth/token?service=api`) + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST'); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN); + expect(response.headers['x-forest-bff-version']).toBeDefined(); + }); + + it('should serve the MCP discovery metadata under the base path suffix', async () => { + const response = await request(gateway.url).get(`/.well-known/oauth-authorization-server${P}`); + + expect(response.status).toBe(200); + expect(response.body.token_endpoint).toBe(`http://localhost:${gateway.port}${P}/oauth/token`); + }); + + it('should not serve the API token endpoint under /api', async () => { + const response = await request(gateway.url).post(`${P}/api/oauth/token`); + + expect(response.status).toBe(404); + }); + + it('should not alias the API agent routes at the root', async () => { + const response = await request(gateway.url).get('/agent/v1/books'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: 'not_found', + error_description: 'No Gateway service serves this path.', + }); + }); + + it('should serve the API health, docs and docs bundle', async () => { + const [health, docs, bundle] = await Promise.all([ + request(gateway.url).get(`${P}/api/health`), + request(gateway.url).get(`${P}/api/docs`), + request(gateway.url).get(`${P}/api/docs/redoc.standalone.js`), + ]); + + expect([health.status, docs.status, bundle.status]).toEqual([200, 200, 200]); + expect(docs.text).toContain(`${P}/api/docs/redoc.standalone.js`); + }); + + it('should point the docs page at the OpenAPI document under /api', async () => { + const response = await request(gateway.url).get(`${P}/api/docs`); + + expect(response.text).toContain(`${P}/api/agent/openapi.json`); + }); + + it('should report both services healthy on the root /health', async () => { + const response = await request(gateway.url).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + healthy: true, + version, + services: { + mcp: 'ok', + api: { status: 'ok', configured: { oauth: true, ai: true, cors: true, openapi: true } }, + }, + }); + }); + + it.each([ + ['the MCP', 'post', `${P}/oauth/token`], + ['the API', 'get', `${P}/api/health`], + ['the root health', 'get', '/health'], + ['the fallback', 'get', '/nowhere'], + ])('should send the gateway version on %s', async (_, method, path) => { + const response = await request(gateway.url)[method](path); + + expect(response.headers['x-forest-gateway-version']).toBe(version); + }); +}); + +describe('base path /ai', () => { + let gateway: RunningGateway; + + beforeAll(async () => { + gateway = await startGateway({ FOREST_GATEWAY_BASE_PATH: '/ai' }); + }); + + afterAll(async () => { + await gateway.server.stop(); + }); + + it.each([ + ['post', '/mcp'], + ['get', '/api/agent/v1/books'], + ['get', '/api/health'], + ])('should answer 404 on %s %s at the root', async (method, path) => { + const response = await request(gateway.url)[method](path); + + expect(response.status).toBe(404); + }); +}); + +describe('only the API', () => { + let gateway: RunningGateway; + + beforeAll(async () => { + gateway = await startGateway({ FOREST_GATEWAY_SERVICES: 'api' }); + }); + + afterAll(async () => { + await gateway.server.stop(); + }); + + it('should fall back to the gateway 404 on the token endpoint without service', async () => { + const response = await request(gateway.url).post('/oauth/token'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: 'not_found', + error_description: 'No Gateway service serves this path.', + }); + }); + + it('should report the MCP disabled on the root /health', async () => { + const response = await request(gateway.url).get('/health'); + + expect(response.status).toBe(200); + expect(response.body.services.mcp).toBe('disabled'); + }); +}); + +describe('only the MCP', () => { + let gateway: RunningGateway; + + beforeAll(async () => { + gateway = await startGateway({ FOREST_GATEWAY_SERVICES: 'mcp' }); + }); + + afterAll(async () => { + await gateway.server.stop(); + }); + + it('should answer 404 on the API token endpoint', async () => { + const response = await request(gateway.url).post('/oauth/token?service=api'); + + expect(response.status).toBe(404); + }); + + it('should report the API disabled on the root /health', async () => { + const response = await request(gateway.url).get('/health'); + + expect(response.status).toBe(200); + expect(response.body.services).toEqual({ mcp: 'ok', api: 'disabled' }); + }); + + it('should name the listener Forest Gateway in its logs', () => { + expect(gateway.logger).toHaveBeenCalledWith('Info', 'Forest Gateway started', { + port: expect.any(Number), + }); + expect(gateway.logger).not.toHaveBeenCalledWith( + 'Info', + 'Forest BFF started', + expect.anything(), + ); + }); +}); + +describe('an MCP missing a secret', () => { + describe.each(['FOREST_AUTH_SECRET', 'FOREST_ENV_SECRET'])('without %s', missing => { + let gateway: RunningGateway; + + beforeAll(async () => { + gateway = await startGateway({ [missing]: undefined }); + }); + + afterAll(async () => { + await gateway.server.stop(); + }); + + it.each([ + ['post', '/mcp'], + ['post', '/oauth/token'], + ['get', '/.well-known/oauth-authorization-server'], + ])('should answer 503 on %s %s', async (method, path) => { + const response = await request(gateway.url)[method](path); + + expect(response.status).toBe(503); + expect(response.body.error).toBe('service_unavailable'); + }); + + it('should report the MCP degraded on the root /health', async () => { + const response = await request(gateway.url).get('/health'); + + expect(response.status).toBe(503); + expect(response.body.healthy).toBe(false); + expect(response.body.services.mcp).toBe('degraded'); + }); + }); + + it('should report 503 with only the MCP enabled', async () => { + const gateway = await startGateway({ + FOREST_GATEWAY_SERVICES: 'mcp', + FOREST_AUTH_SECRET: undefined, + }); + + try { + const response = await request(gateway.url).get('/health'); + + expect(response.status).toBe(503); + expect(response.body.services).toEqual({ mcp: 'degraded', api: 'disabled' }); + } finally { + await gateway.server.stop(); + } + }); +}); + +describe('a degraded API', () => { + let gateway: RunningGateway; + + beforeAll(async () => { + gateway = await startGateway({ FOREST_GATEWAY_SERVICES: 'api', FOREST_AGENT_URL: undefined }); + }); + + afterAll(async () => { + await gateway.server.stop(); + }); + + it('should report the same API state on /api/health and the root /health', async () => { + const [apiHealth, rootHealth] = await Promise.all([ + request(gateway.url).get('/api/health'), + request(gateway.url).get('/health'), + ]); + + expect(apiHealth.status).toBe(503); + expect(rootHealth.status).toBe(503); + expect(rootHealth.body.services.api).toEqual({ + status: apiHealth.body.status, + configured: apiHealth.body.configured, + }); + expect(apiHealth.body.status).toBe('degraded'); + }); +}); + +describe('an unreachable Forest server', () => { + let gateway: RunningGateway; + + beforeAll(async () => { + const closedPort = await getAvailablePort(); + gateway = await startGateway({ FOREST_SERVER_URL: `http://127.0.0.1:${closedPort}` }); + }); + + afterAll(async () => { + await gateway.server.stop(); + }); + + it('should still report healthy on the root /health', async () => { + const response = await request(gateway.url).get('/health'); + + expect(response.status).toBe(200); + expect(response.body.healthy).toBe(true); + }); +}); + +describe('boot log', () => { + it('should announce the API OAuth route when the encryption key is set', async () => { + const gateway = await startGateway({ FOREST_GATEWAY_BASE_PATH: '/ai' }); + + try { + expect(gateway.logger).toHaveBeenCalledWith( + 'Info', + 'API service on /ai/api/agent/*', + expect.objectContaining({ oauth: '/ai/oauth/*?service=api' }), + ); + } finally { + await gateway.server.stop(); + } + }); + + it('should say the API OAuth is off without the encryption key', async () => { + const gateway = await startGateway({ FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY: undefined }); + + try { + expect(gateway.logger).toHaveBeenCalledWith( + 'Info', + 'API service on /api/agent/*', + expect.objectContaining({ oauth: 'API OAuth off' }), + ); + } finally { + await gateway.server.stop(); + } + }); + + it('should warn about a legacy listener variable it reads', async () => { + const port = await getAvailablePort(); + const gateway = await startGateway({ PORT: undefined, MCP_SERVER_PORT: String(port) }); + + try { + expect(gateway.logger).toHaveBeenCalledWith( + 'Warn', + 'MCP_SERVER_PORT is a legacy name: use PORT instead', + ); + expect((await request(`http://127.0.0.1:${port}`).get('/health')).status).toBe(200); + } finally { + await gateway.server.stop(); + } + }); +}); + +describe('configuration', () => { + async function withGateway( + overrides: NodeJS.ProcessEnv, + check: (gateway: RunningGateway) => Promise | void, + ): Promise { + const gateway = await startGateway(overrides); + + try { + await check(gateway); + } finally { + await gateway.server.stop(); + } + } + + it('should start without the API encryption key, saying API sign-in is off', async () => { + await withGateway( + { FOREST_GATEWAY_BASE_PATH: '/ai', FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY: undefined }, + async gateway => { + const response = await request(gateway.url).get('/health'); + + expect(response.body.services.api.configured.oauth).toBe(false); + expect(gateway.logger).toHaveBeenCalledWith( + 'Warn', + 'API sign-in (/ai/oauth/*?service=api) is off', + { set: 'FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY' }, + ); + }, + ); + }); + + it('should start the API degraded without an agent url, naming FOREST_AGENT_URL', async () => { + await withGateway( + { FOREST_GATEWAY_SERVICES: 'api', FOREST_AGENT_URL: undefined }, + async gateway => { + const response = await request(gateway.url).get('/health'); + + expect(response.status).toBe(503); + expect(response.body.services.api.status).toBe('degraded'); + expect(gateway.logger).toHaveBeenCalledWith( + 'Warn', + 'Missing required configuration; /health will report degraded', + { missing: ['FOREST_AGENT_URL'] }, + ); + }, + ); + }); + + it('should start from a forest-bff configuration, never logging the encryption key', async () => { + await withGateway( + { + FOREST_GATEWAY_SERVICES: 'api', + FOREST_AGENT_URL: undefined, + FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY: undefined, + FOREST_GATEWAY_API_ALLOWED_ORIGINS: undefined, + AGENT_URL: 'https://agent.example.com', + BFF_PUBLIC_URL: 'https://corp.example/api', + BFF_TOKEN_ENCRYPTION_KEY: ENCRYPTION_KEY, + BFF_ALLOWED_ORIGINS: ALLOWED_ORIGIN, + }, + async gateway => { + const response = await request(gateway.url).get('/health'); + const warnings = gateway.logger.mock.calls + .filter(([level]) => level === 'Warn') + .map(([, message]) => message); + + expect(response.status).toBe(200); + expect(warnings).toEqual([ + 'AGENT_URL is a legacy name: use FOREST_AGENT_URL instead', + 'BFF_PUBLIC_URL is a legacy name: use FOREST_GATEWAY_API_PUBLIC_URL instead', + 'BFF_TOKEN_ENCRYPTION_KEY is a legacy name: use FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY instead', + 'BFF_ALLOWED_ORIGINS is a legacy name: use FOREST_GATEWAY_API_ALLOWED_ORIGINS instead', + ]); + expect(JSON.stringify(gateway.logger.mock.calls)).not.toContain(ENCRYPTION_KEY); + }, + ); + }); + + it('should start from the hosted API configuration under its Gateway names', async () => { + const port = await getAvailablePort(); + const logger = jest.fn(); + const server = await runGateway( + { + FOREST_GATEWAY_SERVICES: 'api', + PORT: String(port), + FOREST_AGENT_URL: 'https://agent.example.com', + FOREST_SERVER_URL: forest.url, + FOREST_APP_URL: 'https://app.development.forestadmin.com', + FOREST_AUTH_SECRET: 'auth-secret', + FOREST_ENV_SECRET: 'env-secret', + FOREST_GATEWAY_API_PUBLIC_URL: 'https://agent-bff.development.forestadmin.com/api', + FOREST_GATEWAY_API_TOKEN_ENCRYPTION_KEY: ENCRYPTION_KEY, + FOREST_GATEWAY_API_ALLOWED_ORIGINS: 'http://localhost:5173', + FOREST_GATEWAY_API_DEFAULT_TIMEZONE: 'Europe/Paris', + FOREST_GATEWAY_API_OPENAPI_ENABLED: 'true', + }, + logger as unknown as Logger, + ); + + try { + const response = await request(`http://127.0.0.1:${port}`).get('/health'); + + expect(response.status).toBe(200); + expect(response.body.services.api.configured).toEqual({ + oauth: true, + ai: true, + cors: true, + openapi: true, + }); + expect(logger.mock.calls.filter(([level]) => level === 'Warn')).toEqual([]); + } finally { + await server.stop(); + } + }); + + it('should drop an invalid allowed origin, warning naming its key', async () => { + await withGateway( + { FOREST_GATEWAY_API_ALLOWED_ORIGINS: `${ALLOWED_ORIGIN},not-an-origin` }, + async gateway => { + const response = await request(gateway.url) + .options('/oauth/token?service=api') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST'); + + expect(response.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN); + expect(gateway.logger).toHaveBeenCalledWith( + 'Warn', + 'Ignoring malformed FOREST_GATEWAY_API_ALLOWED_ORIGINS entries', + { entries: ['not-an-origin'] }, + ); + }, + ); + }); + + it('should fail on an invalid MCP token TTL even when the MCP would be degraded', async () => { + await expect( + startGateway({ + FOREST_GATEWAY_SERVICES: 'mcp', + FOREST_AUTH_SECRET: undefined, + FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS: 'soon', + }), + ).rejects.toThrow('Invalid FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS'); + }); + + it('should not load the upload storage module when uploads are off', async () => { + await withGateway( + { + FOREST_GATEWAY_SERVICES: 'mcp', + FOREST_MCP_FILE_UPLOADS: 'false', + FOREST_MCP_UPLOAD_STORAGE_MODULE: './does-not-exist', + }, + async gateway => { + const response = await request(gateway.url).get('/health'); + + expect(response.body.services.mcp).toBe('ok'); + }, + ); + }); + + it('should keep the MCP healthy without a gateway url, warning naming it', async () => { + await withGateway({ FOREST_GATEWAY_SERVICES: 'mcp' }, async gateway => { + const response = await request(gateway.url).get('/health'); + + expect(response.body.services.mcp).toBe('ok'); + expect(gateway.logger).toHaveBeenCalledWith( + 'Warn', + `FOREST_GATEWAY_URL is not set: MCP clients are told http://localhost:${gateway.port}`, + ); + }); + }); + + it('should fail once on an agent url with a query string, without its value', async () => { + await expect( + startGateway({ FOREST_AGENT_URL: 'https://agent.example.com?token=do-not-print-me' }), + ).rejects.toThrow( + 'Invalid FOREST_AGENT_URL: expected an absolute http(s) URL with no query string or fragment.', + ); + }); +}); diff --git a/packages/gateway/test/shutdown.test.ts b/packages/gateway/test/shutdown.test.ts new file mode 100644 index 0000000000..4a47de2533 --- /dev/null +++ b/packages/gateway/test/shutdown.test.ts @@ -0,0 +1,190 @@ +import type { FakeForestServer } from './helpers/fake-forest-server'; +import type { ChildProcess } from 'child_process'; + +import { spawn } from 'child_process'; +import net from 'net'; +import path from 'path'; + +import startFakeForestServer from './helpers/fake-forest-server'; +import { gatewayEnv, getAvailablePort } from './helpers/gateway-env'; + +const CLI = path.resolve(__dirname, '../src/cli.ts'); +const SHUTDOWN_TIMEOUT_MS = 10_000; +const SLOW_TEST_TIMEOUT_MS = 30_000; +const TOKEN_BODY = 'grant_type=authorization_code'; + +interface SpawnedGateway { + port: number; + child: ChildProcess; + output: () => string; + exited: Promise<{ code: number | null; elapsedMs: number }>; +} + +let forest: FakeForestServer; + +beforeAll(async () => { + forest = await startFakeForestServer(); +}); + +afterAll(async () => { + await forest.close(); +}); + +async function spawnGateway(): Promise { + const port = await getAvailablePort(); + const child = spawn(process.execPath, ['-r', 'ts-node/register/transpile-only', CLI], { + env: { PATH: process.env.PATH, ...gatewayEnv(forest.url, port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + let signalledAt = 0; + + child.stdout?.on('data', chunk => { + output += chunk; + }); + child.stderr?.on('data', chunk => { + output += chunk; + }); + + const originalKill = child.kill.bind(child); + + child.kill = (signal?: NodeJS.Signals | number) => { + signalledAt ||= Date.now(); + + return originalKill(signal); + }; + + const exited = new Promise<{ code: number | null; elapsedMs: number }>(resolve => { + child.on('exit', code => resolve({ code, elapsedMs: Date.now() - signalledAt })); + }); + + await new Promise((resolve, reject) => { + const poll = setInterval(() => { + if (output.includes('Forest Gateway started')) { + clearInterval(poll); + resolve(); + } + }, 50); + + child.on('exit', code => { + clearInterval(poll); + reject(new Error(`The gateway exited before listening (${code}): ${output}`)); + }); + }); + + return { port, child, output: () => output, exited }; +} + +function openTokenRequest(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1', () => { + socket.write( + 'POST /oauth/token?service=api HTTP/1.1\r\n' + + 'Host: 127.0.0.1\r\n' + + 'Content-Type: application/x-www-form-urlencoded\r\n' + + `Content-Length: ${TOKEN_BODY.length}\r\n` + + 'Connection: close\r\n\r\n' + + `${TOKEN_BODY.slice(0, 5)}`, + ); + resolve(socket); + }); + + socket.on('error', reject); + }); +} + +function readResponse(socket: net.Socket): Promise { + return new Promise(resolve => { + let response = ''; + + socket.on('data', chunk => { + response += chunk; + }); + socket.on('close', () => resolve(response)); + }); +} + +function wait(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +describe('forest-gateway shutdown', () => { + it.each(['SIGTERM', 'SIGINT'] as NodeJS.Signals[])( + 'should exit 0 on %s', + async signal => { + const gateway = await spawnGateway(); + + gateway.child.kill(signal); + const { code, elapsedMs } = await gateway.exited; + + expect(code).toBe(0); + expect(elapsedMs).toBeLessThan(SHUTDOWN_TIMEOUT_MS); + }, + SLOW_TEST_TIMEOUT_MS, + ); + + it.each([ + ['SIGTERM', 'SIGTERM'], + ['SIGTERM', 'SIGINT'], + ] as NodeJS.Signals[][])( + 'should stop once on %s then %s', + async (first, second) => { + const gateway = await spawnGateway(); + const socket = await openTokenRequest(gateway.port); + + await wait(200); + gateway.child.kill(first); + await wait(200); + gateway.child.kill(second); + await wait(200); + socket.write(TOKEN_BODY.slice(5)); + const { code } = await gateway.exited; + + expect(code).toBe(0); + expect(gateway.output().match(/Stopping the Forest Gateway/g)).toHaveLength(1); + expect(gateway.output()).toContain( + 'Ignoring the signal: the Forest Gateway is already stopping', + ); + }, + SLOW_TEST_TIMEOUT_MS, + ); + + it( + 'should let an in-flight API request finish before exiting', + async () => { + const gateway = await spawnGateway(); + const socket = await openTokenRequest(gateway.port); + const response = readResponse(socket); + + await wait(200); + gateway.child.kill('SIGTERM'); + await wait(200); + socket.write(TOKEN_BODY.slice(5)); + + expect(await response).toMatch(/^HTTP\/1\.1 400 /); + expect((await gateway.exited).code).toBe(0); + }, + SLOW_TEST_TIMEOUT_MS, + ); + + it( + 'should close a request still open past the shutdown deadline', + async () => { + const gateway = await spawnGateway(); + const socket = await openTokenRequest(gateway.port); + const response = readResponse(socket); + + await wait(200); + gateway.child.kill('SIGTERM'); + const { code, elapsedMs } = await gateway.exited; + + expect(code).toBe(0); + expect(elapsedMs).toBeGreaterThanOrEqual(SHUTDOWN_TIMEOUT_MS); + expect(await response).toBe(''); + expect(gateway.output()).toContain('Forcing the Forest Gateway shutdown'); + }, + SLOW_TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/gateway/tsconfig.eslint.json b/packages/gateway/tsconfig.eslint.json new file mode 100644 index 0000000000..9bdc52705d --- /dev/null +++ b/packages/gateway/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.eslint.json" +} diff --git a/packages/gateway/tsconfig.json b/packages/gateway/tsconfig.json new file mode 100644 index 0000000000..e0d66374ae --- /dev/null +++ b/packages/gateway/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index c0ca93b959..5ed8e009f1 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -1,5 +1,10 @@ # @forestadmin/mcp-server +> [!IMPORTANT] +> Internal building block of the Forest Gateway. For new deployments, use +> [`@forestadmin/gateway`](https://docs.forestadmin.com/product/embed/gateway-standalone). The +> `forest-mcp-server` command keeps working, deprecated. + Model Context Protocol (MCP) server for Forest Admin with OAuth authentication support. ## Overview diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 535761a34b..3ec89a0a94 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -23,7 +23,7 @@ "express": "^5.2.1", "jsonapi-serializer": "^3.6.9", "jsonwebtoken": "^9.0.3", - "zod": "^4.3.5" + "zod": "4.6.5" }, "files": [ "dist/**/*.js", diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index b8d5ece5a1..1e90a9359d 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -4,7 +4,14 @@ import parseMcpEnv from './mcp-env'; import ForestMCPServer from './server'; import loadFileUploads from './utils/load-file-uploads'; +const DEPRECATION_WARNING = + 'forest-mcp-server is deprecated: it keeps working, but new deployments should run ' + + 'forest-gateway (@forestadmin/gateway): ' + + 'https://docs.forestadmin.com/product/embed/gateway-standalone'; + async function main() { + console.warn(DEPRECATION_WARNING); + const { options, uploadStorageModule } = parseMcpEnv(process.env); const fileUploads = await loadFileUploads(uploadStorageModule); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 165b3d058a..2745c0fcfd 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -3,7 +3,7 @@ export { default as ForestMCPServer } from './server'; export type { ForestMCPServerOptions, HttpCallback, ToolName } from './server'; export { default as createGatewaySwitch } from './gateway-switch'; export type { GatewayHandler, GatewaySwitchOptions } from './gateway-switch'; -export { default as parseMcpEnv } from './mcp-env'; +export { default as parseMcpEnv, MCP_LISTENER_ENV } from './mcp-env'; export type { McpEnv, McpEnvLabels, McpListenerSettings, ParsedMcpEnv } from './mcp-env'; export { default as loadFileUploads } from './utils/load-file-uploads'; export type { TokenTtlOptions } from './utils/token-ttl'; diff --git a/packages/mcp-server/src/mcp-env.ts b/packages/mcp-server/src/mcp-env.ts index cbff642b24..af250fe1d1 100644 --- a/packages/mcp-server/src/mcp-env.ts +++ b/packages/mcp-server/src/mcp-env.ts @@ -39,14 +39,19 @@ export type ParsedMcpEnv = { uploadStorageModule?: string; }; +export const MCP_LISTENER_ENV = { + port: 'MCP_SERVER_PORT', + publicUrl: 'FOREST_MCP_SERVER_URL', +} as const; + export const DEFAULT_MCP_ENV_LABELS: McpEnvLabels = { accessTokenTtl: 'tokenTtl.accessTokenSeconds', refreshTokenTtl: 'tokenTtl.refreshTokenSeconds', fileUploads: 'FOREST_MCP_FILE_UPLOADS', uploadStorageModule: 'FOREST_MCP_UPLOAD_STORAGE_MODULE', agentUrl: 'agentUrl', - port: 'MCP_SERVER_PORT', - publicUrl: 'FOREST_MCP_SERVER_URL', + port: MCP_LISTENER_ENV.port, + publicUrl: MCP_LISTENER_ENV.publicUrl, }; const MAX_PORT = 65535; @@ -124,8 +129,8 @@ export function parseMcpListenerEnv( const { port, publicUrl } = resolveLabels(labels); return { - port: parsePort(env.MCP_SERVER_PORT, port), - publicUrl: parsePublicUrl(env.FOREST_MCP_SERVER_URL, publicUrl), + port: parsePort(env[MCP_LISTENER_ENV.port], port), + publicUrl: parsePublicUrl(env[MCP_LISTENER_ENV.publicUrl], publicUrl), }; } diff --git a/packages/mcp-server/test/cli.test.ts b/packages/mcp-server/test/cli.test.ts index 8a32eed402..0387c5af82 100644 --- a/packages/mcp-server/test/cli.test.ts +++ b/packages/mcp-server/test/cli.test.ts @@ -97,6 +97,20 @@ describe('forest-mcp-server', () => { expect(Server.mock.results[0].value.run).toHaveBeenCalledWith(); }); + it('warns once that it is deprecated in favor of forest-gateway', async () => { + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); + + await startCli({}); + + const deprecations = consoleWarn.mock.calls.filter(([message]) => + String(message).includes('deprecated'), + ); + + expect(deprecations).toHaveLength(1); + expect(deprecations[0][0]).toContain('forest-mcp-server is deprecated'); + expect(deprecations[0][0]).toContain('forest-gateway'); + }); + it("passes fileUploads: false on FOREST_MCP_FILE_UPLOADS='false', module or not", async () => { const { Server } = await startCli({ FOREST_MCP_FILE_UPLOADS: 'false', diff --git a/packages/mcp-server/test/index.test.ts b/packages/mcp-server/test/index.test.ts index 8bb7f627ab..5c82f9e28f 100644 --- a/packages/mcp-server/test/index.test.ts +++ b/packages/mcp-server/test/index.test.ts @@ -1,6 +1,7 @@ import { ForestMCPServer, ForestServerClientImpl, + MCP_LISTENER_ENV, MCP_PATHS, createForestServerClient, createGatewaySwitch, @@ -43,4 +44,13 @@ describe('mcp-server exports', () => { }); await expect(loadFileUploads(undefined)).resolves.toBeUndefined(); }); + + it('should export the listener variables parseMcpEnv reads', () => { + expect( + parseMcpEnv({ + [MCP_LISTENER_ENV.port]: '4000', + [MCP_LISTENER_ENV.publicUrl]: 'https://mcp.example.com', + }).listener, + ).toEqual({ port: 4000, publicUrl: 'https://mcp.example.com' }); + }); }); From 2a54a8e50a8d26bb8c8bb14f045a6157dec0c0e7 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 12 Oct 2026 14:38:05 +0200 Subject: [PATCH 06/11] feat(mcp-server): accept a service account credential (#1944) * feat(mcp-server): accept a service account credential An fgw_ or fbff_ bearer is resolved on the Forest server with service mcp, cached, and turned into a short-lived agent token. The resolve client lives in forestadmin-client so mcp-server imports nothing from agent-bff. Co-Authored-By: Claude Opus 5.5 * fix(mcp-server): validate tags and log why a credential resolve failed Co-Authored-By: Claude Opus 5.5 * fix(forestadmin-client): keep why a gateway key resolve was unreachable Co-Authored-By: Claude Opus 5.5 * fix(mcp-server): sign the service account agent token in the shared claims shape Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: sim --- .../src/gateway-api-key/index.ts | 164 +++++++++++ .../src/gateway-api-key/resolve-error.ts | 44 +++ packages/forestadmin-client/src/index.ts | 11 + .../test/gateway-api-key/index.test.ts | 265 ++++++++++++++++++ packages/mcp-server/CLAUDE.md | 1 + packages/mcp-server/README.md | 2 + .../mcp-server/src/forest-oauth-provider.ts | 56 +++- .../src/gateway-api-key-authenticator.ts | 171 +++++++++++ .../test/forest-oauth-provider.test.ts | 165 ++++++++++- .../gateway-api-key-authenticator.test.ts | 249 ++++++++++++++++ 10 files changed, 1125 insertions(+), 3 deletions(-) create mode 100644 packages/forestadmin-client/src/gateway-api-key/index.ts create mode 100644 packages/forestadmin-client/src/gateway-api-key/resolve-error.ts create mode 100644 packages/forestadmin-client/test/gateway-api-key/index.test.ts create mode 100644 packages/mcp-server/src/gateway-api-key-authenticator.ts create mode 100644 packages/mcp-server/test/gateway-api-key-authenticator.test.ts diff --git a/packages/forestadmin-client/src/gateway-api-key/index.ts b/packages/forestadmin-client/src/gateway-api-key/index.ts new file mode 100644 index 0000000000..114b1bcbb6 --- /dev/null +++ b/packages/forestadmin-client/src/gateway-api-key/index.ts @@ -0,0 +1,164 @@ +import GatewayApiKeyResolveError from './resolve-error'; + +export { default as GatewayApiKeyResolveError } from './resolve-error'; +export type { GatewayApiKeyResolveErrorParams } from './resolve-error'; + +const API_KEY_PATTERN = /^(?:fgw|fbff)_([0-9a-f]{16})_([0-9a-f]{64})$/; + +const DEFAULT_HEADERS = { 'Content-Type': 'application/json' } as const; +const REQUEST_TIMEOUT_MS = 10_000; +const RESOLVE_PATH = '/liana/v1/bff-api-keys/resolve'; + +export type GatewayService = 'api' | 'mcp'; + +export interface ParsedGatewayApiKey { + keyId: string; + secret: string; +} + +export interface GatewayApiKeyIdentityUser { + id: number; + email: string; + firstName: string | null; + lastName: string | null; + team: string; + tags: { key: string; value: string }[]; + permissionLevel: string; +} + +export interface ResolvedGatewayApiKeyIdentity { + user: GatewayApiKeyIdentityUser; + renderingId: number; + saasAccessToken?: string; +} + +export interface GatewayApiKeyClientOptions { + forestServerUrl: string; + envSecret: string; + service: GatewayService; +} + +interface SaasErrorBody { + errors?: { name?: string; meta?: { code?: string } }[]; +} + +export function parseGatewayApiKey(raw: string): ParsedGatewayApiKey | null { + const match = API_KEY_PATTERN.exec(raw); + + if (!match) return null; + + return { keyId: match[1], secret: match[2] }; +} + +// Calls fetch rather than ServerUtils: the caller needs the refusal's `meta.code` and `Retry-After`, +// which ServerUtils drops when it maps a response to a typed error. +export class GatewayApiKeyClient { + private readonly forestServerUrl: string; + private readonly envSecret: string; + private readonly service: GatewayService; + + constructor({ forestServerUrl, envSecret, service }: GatewayApiKeyClientOptions) { + this.forestServerUrl = forestServerUrl; + this.envSecret = envSecret; + this.service = service; + } + + async resolve(parsedKey: ParsedGatewayApiKey): Promise { + let response: Response; + + try { + response = await fetch(new URL(RESOLVE_PATH, this.forestServerUrl).toString(), { + method: 'POST', + headers: { ...DEFAULT_HEADERS, 'forest-secret-key': this.envSecret }, + body: JSON.stringify({ + keyId: parsedKey.keyId, + secret: parsedKey.secret, + service: this.service, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new GatewayApiKeyResolveError({ unreachable: true, cause: error }); + } + + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as SaasErrorBody; + const firstError = body.errors?.[0]; + + throw new GatewayApiKeyResolveError({ + status: response.status, + code: firstError?.meta?.code, + name: firstError?.name, + retryAfter: GatewayApiKeyClient.parseRetryAfter(response.headers.get('retry-after')), + }); + } + + let body: unknown; + + try { + body = await response.json(); + } catch (error) { + throw new GatewayApiKeyResolveError({ unreachable: true, cause: error }); + } + + if (!GatewayApiKeyClient.isResolvedIdentity(body)) { + throw new GatewayApiKeyResolveError({ + unreachable: true, + cause: new Error('the resolve answered 200 without a valid identity'), + }); + } + + return { + user: body.user, + renderingId: body.renderingId, + saasAccessToken: body.saasAccessToken, + }; + } + + private static isResolvedIdentity(body: unknown): body is ResolvedGatewayApiKeyIdentity { + if (typeof body !== 'object' || body === null) return false; + + const candidate = body as { + user?: unknown; + renderingId?: unknown; + saasAccessToken?: unknown; + }; + + return ( + typeof candidate.renderingId === 'number' && + (candidate.saasAccessToken === undefined || typeof candidate.saasAccessToken === 'string') && + GatewayApiKeyClient.isIdentityUser(candidate.user) + ); + } + + private static isIdentityUser(user: unknown): user is GatewayApiKeyIdentityUser { + if (typeof user !== 'object' || user === null) return false; + + const candidate = user as Record; + + return ( + typeof candidate.id === 'number' && + typeof candidate.email === 'string' && + typeof candidate.team === 'string' && + typeof candidate.permissionLevel === 'string' && + Array.isArray(candidate.tags) && + candidate.tags.every(GatewayApiKeyClient.isTag) + ); + } + + private static isTag(tag: unknown): tag is { key: string; value: string } { + if (typeof tag !== 'object' || tag === null) return false; + + const candidate = tag as Record; + + return typeof candidate.key === 'string' && typeof candidate.value === 'string'; + } + + private static parseRetryAfter(header: string | null): number | undefined { + if (header === null) return undefined; + + const seconds = Number(header); + + return Number.isInteger(seconds) && seconds > 0 ? seconds : undefined; + } +} diff --git a/packages/forestadmin-client/src/gateway-api-key/resolve-error.ts b/packages/forestadmin-client/src/gateway-api-key/resolve-error.ts new file mode 100644 index 0000000000..5ca7c23f28 --- /dev/null +++ b/packages/forestadmin-client/src/gateway-api-key/resolve-error.ts @@ -0,0 +1,44 @@ +export interface GatewayApiKeyResolveErrorParams { + status?: number; + code?: string; + name?: string; + retryAfter?: number; + unreachable?: boolean; + cause?: unknown; +} + +function describeCause(cause: unknown): string { + if (!(cause instanceof Error)) return String(cause); + + const nested = (cause as { cause?: unknown }).cause; + + return nested ? `${cause.message}: ${describeCause(nested)}` : cause.message; +} + +function describe({ status, code, unreachable, cause }: GatewayApiKeyResolveErrorParams): string { + if (unreachable) return cause ? ` (unreachable: ${describeCause(cause)})` : ' (unreachable)'; + + const details = [status && `status ${status}`, code && `code ${code}`].filter(Boolean); + + return details.length ? ` (${details.join(', ')})` : ''; +} + +export default class GatewayApiKeyResolveError extends Error { + readonly status?: number; + readonly code?: string; + readonly saasName?: string; + readonly retryAfter?: number; + readonly unreachable: boolean; + readonly cause?: unknown; + + constructor(params: GatewayApiKeyResolveErrorParams) { + super(`Gateway API key resolve failed${describe(params)}`); + this.name = 'GatewayApiKeyResolveError'; + this.status = params.status; + this.code = params.code; + this.saasName = params.name; + this.retryAfter = params.retryAfter; + this.unreachable = params.unreachable ?? false; + this.cause = params.cause; + } +} diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 9df2c9d979..6f070c634e 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -128,6 +128,17 @@ export { default as ServerUtils } from './utils/server'; export { default as SchemaService, SchemaServiceOptions } from './schema'; export { default as ActivityLogsService, ActivityLogsOptions } from './activity-logs'; export { default as WorkflowsService, WorkflowsServiceOptions } from './workflows'; +export { + GatewayApiKeyClient, + GatewayApiKeyClientOptions, + GatewayApiKeyIdentityUser, + GatewayApiKeyResolveError, + GatewayApiKeyResolveErrorParams, + GatewayService, + ParsedGatewayApiKey, + ResolvedGatewayApiKeyIdentity, + parseGatewayApiKey, +} from './gateway-api-key'; export { default as parseDomainList } from './oauth-client-allowlist/parse-domain-list'; export { default as normalizeDomainList } from './oauth-client-allowlist/normalize-domain-list'; export { default as isClientAllowed } from './oauth-client-allowlist/is-client-allowed'; diff --git a/packages/forestadmin-client/test/gateway-api-key/index.test.ts b/packages/forestadmin-client/test/gateway-api-key/index.test.ts new file mode 100644 index 0000000000..d003972271 --- /dev/null +++ b/packages/forestadmin-client/test/gateway-api-key/index.test.ts @@ -0,0 +1,265 @@ +import { + GatewayApiKeyClient, + GatewayApiKeyResolveError, + parseGatewayApiKey, +} from '../../src/gateway-api-key'; + +const KEY_ID = 'a'.repeat(16); +const SECRET = 'b'.repeat(64); +const OPTS = { + forestServerUrl: 'https://api.forestadmin.com', + envSecret: 'env-secret', + service: 'mcp' as const, +}; +const PARSED = { keyId: KEY_ID, secret: SECRET }; + +const USER = { + id: 1, + email: 'a@b.c', + firstName: 'A', + lastName: 'B', + team: 'T', + tags: [{ key: 'k', value: 'v' }], + permissionLevel: 'user', +}; + +const IDENTITY = { user: USER, renderingId: 17, saasAccessToken: 'saas-token' }; + +function fakeResponse( + status: number, + body: unknown, + headers: Record = {}, +): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (key: string) => headers[key.toLowerCase()] ?? null }, + json: async () => body, + } as unknown as Response; +} + +const originalFetch = global.fetch; + +function mockFetch(impl: jest.Mock): void { + global.fetch = impl as unknown as typeof fetch; +} + +describe('parseGatewayApiKey', () => { + it.each(['fgw', 'fbff'])('should parse a %s_ key into its keyId and secret', prefix => { + expect(parseGatewayApiKey(`${prefix}_${KEY_ID}_${SECRET}`)).toEqual(PARSED); + }); + + it.each([ + ['another prefix', `fxx_${KEY_ID}_${SECRET}`], + ['no prefix', `${KEY_ID}_${SECRET}`], + ['a short keyId', `fgw_${'a'.repeat(15)}_${SECRET}`], + ['a short secret', `fgw_${KEY_ID}_${'b'.repeat(63)}`], + ['uppercase hex', `fgw_${'A'.repeat(16)}_${SECRET}`], + ['a JWT', 'eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MX0.sig'], + ['an empty string', ''], + ])('should return null for %s', (_, raw) => { + expect(parseGatewayApiKey(raw)).toBeNull(); + }); +}); + +describe('GatewayApiKeyClient.resolve', () => { + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should POST the key and the service to the resolve endpoint with the env secret', async () => { + const fetchMock = jest.fn(async () => fakeResponse(200, IDENTITY)); + mockFetch(fetchMock); + + await new GatewayApiKeyClient(OPTS).resolve(PARSED); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.forestadmin.com/liana/v1/bff-api-keys/resolve', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'forest-secret-key': 'env-secret', + }, + body: JSON.stringify({ keyId: KEY_ID, secret: SECRET, service: 'mcp' }), + }), + ); + }); + + it('should return the identity on 200', async () => { + mockFetch(jest.fn(async () => fakeResponse(200, IDENTITY))); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).resolves.toEqual(IDENTITY); + }); + + it('should drop fields the identity does not declare', async () => { + mockFetch(jest.fn(async () => fakeResponse(200, { ...IDENTITY, allowedOrigins: ['x'] }))); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).resolves.toEqual(IDENTITY); + }); + + it('should accept an identity without allowedOrigins nor saasAccessToken', async () => { + mockFetch(jest.fn(async () => fakeResponse(200, { user: USER, renderingId: 17 }))); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).resolves.toEqual({ + user: USER, + renderingId: 17, + saasAccessToken: undefined, + }); + }); + + it.each([ + ['missing user', { renderingId: 17 }], + ['missing renderingId', { user: USER }], + ['user missing tags', { user: { ...USER, tags: undefined }, renderingId: 17 }], + ['user missing email', { user: { ...USER, email: undefined }, renderingId: 17 }], + ['a non-string saasAccessToken', { user: USER, renderingId: 17, saasAccessToken: 1 }], + ['a null tag', { user: { ...USER, tags: [null] }, renderingId: 17 }], + ['a tag without value', { user: { ...USER, tags: [{ key: 'k' }] }, renderingId: 17 }], + [ + 'a tag with a non-string key', + { user: { ...USER, tags: [{ key: 1, value: 'v' }] }, renderingId: 17 }, + ], + ['null', null], + ])('should throw an unreachable error on a 200 with %s', async (_, body) => { + mockFetch(jest.fn(async () => fakeResponse(200, body))); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).rejects.toMatchObject({ + unreachable: true, + message: + 'Gateway API key resolve failed (unreachable: the resolve answered 200 without a valid identity)', + }); + }); + + it('should throw an unreachable error on a 200 whose body is not JSON', async () => { + mockFetch( + jest.fn( + async () => + ({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + } as unknown as Response), + ), + ); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).rejects.toMatchObject({ + unreachable: true, + cause: expect.any(SyntaxError), + message: 'Gateway API key resolve failed (unreachable: Unexpected token)', + }); + }); + + it('should keep the fetch failure and its cause when fetch rejects', async () => { + const failure = Object.assign(new TypeError('fetch failed'), { + cause: new Error('connect ECONNREFUSED 127.0.0.1:443'), + }); + mockFetch( + jest.fn(async () => { + throw failure; + }), + ); + + const error = await new GatewayApiKeyClient(OPTS).resolve(PARSED).catch(e => e); + + expect(error).toBeInstanceOf(GatewayApiKeyResolveError); + expect(error.unreachable).toBe(true); + expect(error.cause).toBe(failure); + expect(error.message).toBe( + 'Gateway API key resolve failed (unreachable: fetch failed: connect ECONNREFUSED 127.0.0.1:443)', + ); + }); + + it('should keep a timeout as the cause', async () => { + const timeout = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + mockFetch( + jest.fn(async () => { + throw timeout; + }), + ); + + const error = await new GatewayApiKeyClient(OPTS).resolve(PARSED).catch(e => e); + + expect(error.cause).toBe(timeout); + expect(error.message).toBe( + 'Gateway API key resolve failed (unreachable: TimeoutError: The operation was aborted due to timeout)', + ); + }); + + it('should carry the status, code and name of a refusal', async () => { + mockFetch( + jest.fn(async () => + fakeResponse(403, { + errors: [{ name: 'ForbiddenError', meta: { code: 'plan_feature_missing' } }], + }), + ), + ); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).rejects.toMatchObject({ + status: 403, + code: 'plan_feature_missing', + saasName: 'ForbiddenError', + unreachable: false, + }); + }); + + it('should carry a positive integer retry-after', async () => { + mockFetch(jest.fn(async () => fakeResponse(429, {}, { 'retry-after': '7' }))); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).rejects.toMatchObject({ + status: 429, + retryAfter: 7, + }); + }); + + it.each(['0', '-1', '1.5', 'soon'])('should ignore a retry-after of %s', async header => { + mockFetch(jest.fn(async () => fakeResponse(429, {}, { 'retry-after': header }))); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).rejects.toMatchObject({ + status: 429, + retryAfter: undefined, + }); + }); + + it('should throw a refusal without a code when the error body is not JSON', async () => { + mockFetch( + jest.fn( + async () => + ({ + ok: false, + status: 500, + headers: { get: () => null }, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + } as unknown as Response), + ), + ); + + await expect(new GatewayApiKeyClient(OPTS).resolve(PARSED)).rejects.toMatchObject({ + status: 500, + code: undefined, + }); + }); +}); + +describe('GatewayApiKeyResolveError', () => { + it.each([ + [ + { status: 403, code: 'plan_feature_missing' }, + 'Gateway API key resolve failed (status 403, code plan_feature_missing)', + ], + [{ status: 500 }, 'Gateway API key resolve failed (status 500)'], + [{ unreachable: true }, 'Gateway API key resolve failed (unreachable)'], + [ + { unreachable: true, cause: 'socket hang up' }, + 'Gateway API key resolve failed (unreachable: socket hang up)', + ], + [{}, 'Gateway API key resolve failed'], + ])('should describe %j in its message', (params, message) => { + expect(new GatewayApiKeyResolveError(params).message).toBe(message); + }); +}); diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 5b7742c556..4e1c84cff3 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -14,6 +14,7 @@ Key flows that only make sense across files: - **Per-request MCP server.** `handleMcpRequest` builds a *fresh* `McpServer` + `StreamableHTTPServerTransport` per request (`sessionIdGenerator: undefined`, stateless) and closes the transport on response end. Tool registration in `createMcpServer()` therefore re-runs on every call. - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. +- **Service account credentials bypass OAuth.** `verifyAccessToken` first tries `parseGatewayApiKey` (`fgw_`/`fbff_`, from `forestadmin-client`); on a match, `GatewayApiKeyAuthenticator` (`src/gateway-api-key-authenticator.ts`) resolves it on the Forest server with `service: 'mcp'` (the server checks the plan feature), caches the result (60 s success, 10 s on 401/403), and mints a 5-minute agent JWT that becomes `AuthInfo.token`, so `agent-caller` forwards it unchanged. `forestServerToken` is the resolve's `saasAccessToken`. Refusals map to `InvalidTokenError` (401) or `InsufficientScopeError` (403, never a 401 that would send the client into OAuth); anything else is a `ServerError`. It must not import agent-bff (the Gateway switch forbids it). - **Record-level tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService` / `ActivityLogsService` / `WorkflowsService`) carries schema fetch, activity logging, and — for the workflow tools only — orchestrator data. - **The workflow tools are the exception.** `listWorkflows` / `triggerWorkflow` / `getWorkflowRun` never reach the agent: workflows live in the orchestrator, so these go through `forestServerClient.workflowsService` over the `/api/workflow-orchestrator/mcp-workflows/*` HTTP contract. Two consequences worth knowing before editing them: responses are **projected onto an explicit whitelist** in `forest-http-api.ts` (their payload is stringified straight into a model's context, so a new server field must not arrive by itself) — with one deliberate hole, `stepDefinition`, forwarded whole so the model can reason about the step, which means a field added to a *step* type does reach the model unannounced; and `triggerWorkflow` resolves the workflow by id **before** anything is written so its audit label exists ahead of the side effect. - **Two cross-cutting wrappers.** Every tool uses `registerToolWithLogging`; most, but not all, also use `withActivityLog` — `getActionForm`, `listWorkflows`, `getWorkflowRun` and `requestActionFileUpload` are unaudited (tracked in PRD-967), so do not assume a tool writes an activity log without checking. `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 45c0743c91..3b6743a661 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -445,6 +445,8 @@ Once running, the MCP server exposes the following endpoints: The `/mcp` endpoint expects MCP protocol messages (JSON-RPC 2.0) and requires a valid OAuth Bearer token with at least the `mcp:read` scope. +An autonomous system with no human behind it can send a service account credential instead (`Authorization: Bearer fgw_…`). The Forest server resolves it, and refuses it when the project's plan does not include the Gateway MCP. The session then acts as the service account, with its Role and Teams. + ## Features - **HTTP Transport**: Uses streamable HTTP transport for MCP communication diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index fa47b1044c..a460c6b796 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -1,6 +1,6 @@ import type { Logger } from './server'; import type { TokenTtlOptions } from './utils/token-ttl'; -import type { ForestAdminClient } from '@forestadmin/forestadmin-client'; +import type { ForestAdminClient, ParsedGatewayApiKey } from '@forestadmin/forestadmin-client'; import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; import type { AuthorizationParams, @@ -15,7 +15,11 @@ import type { import type { Response } from 'express'; import { toAgentTokenClaims } from '@forestadmin/agent-client'; -import createForestAdminClient, { isClientAllowed } from '@forestadmin/forestadmin-client'; +import createForestAdminClient, { + GatewayApiKeyClient, + isClientAllowed, + parseGatewayApiKey, +} from '@forestadmin/forestadmin-client'; import { CustomOAuthError, InvalidClientError, @@ -28,6 +32,9 @@ import { import jsonwebtoken from 'jsonwebtoken'; import { z } from 'zod'; +import GatewayApiKeyAuthenticator, { + SERVICE_ACCOUNT_SCOPES, +} from './gateway-api-key-authenticator'; import { sessionRemainingSeconds } from './utils/token-ttl'; const DecodedRefreshTokenSchema = z.object({ @@ -77,6 +84,7 @@ export default class ForestOAuthProvider implements OAuthServerProvider { private envSecret: string; private authSecret: string; private forestClient: ForestAdminClient; + private gatewayApiKeyAuthenticator: GatewayApiKeyAuthenticator; private environmentId?: number; private environmentApiEndpoint?: string; private agentUrl?: string; @@ -107,6 +115,14 @@ export default class ForestOAuthProvider implements OAuthServerProvider { forestServerUrl: this.forestServerUrl, envSecret: this.envSecret, }); + this.gatewayApiKeyAuthenticator = new GatewayApiKeyAuthenticator({ + client: new GatewayApiKeyClient({ + forestServerUrl: this.forestServerUrl, + envSecret: this.envSecret, + service: 'mcp', + }), + authSecret: this.authSecret, + }); } async initialize(): Promise { @@ -522,6 +538,10 @@ export default class ForestOAuthProvider implements OAuthServerProvider { } async verifyAccessToken(token: string): Promise { + const parsedApiKey = parseGatewayApiKey(token); + + if (parsedApiKey) return this.verifyGatewayApiKey(parsedApiKey); + try { const verified = jsonwebtoken.verify(token, this.authSecret, { algorithms: ['HS256'] }); @@ -571,6 +591,38 @@ export default class ForestOAuthProvider implements OAuthServerProvider { } } + private async verifyGatewayApiKey(parsedApiKey: ParsedGatewayApiKey): Promise { + try { + const { identity, agentToken, expiresAt } = + await this.gatewayApiKeyAuthenticator.authenticate(parsedApiKey); + + return { + token: agentToken, + clientId: `service-account-key:${parsedApiKey.keyId}`, + expiresAt, + scopes: [...SERVICE_ACCOUNT_SCOPES], + extra: { + userId: identity.user.id, + email: identity.user.email, + renderingId: identity.renderingId, + environmentApiEndpoint: this.agentUrl ?? this.environmentApiEndpoint, + forestServerToken: identity.saasAccessToken, + }, + }; + } catch (error) { + const { cause } = error as { cause?: unknown }; + + this.logger( + 'Error', + `[ForestOAuthProvider] Service account credential ${parsedApiKey.keyId} refused: ${error}${ + cause ? ` (${cause})` : '' + }`, + ); + + throw error; + } + } + async revokeToken( client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest, diff --git a/packages/mcp-server/src/gateway-api-key-authenticator.ts b/packages/mcp-server/src/gateway-api-key-authenticator.ts new file mode 100644 index 0000000000..799a6db952 --- /dev/null +++ b/packages/mcp-server/src/gateway-api-key-authenticator.ts @@ -0,0 +1,171 @@ +import type { + GatewayApiKeyClient, + ParsedGatewayApiKey, + ResolvedGatewayApiKeyIdentity, +} from '@forestadmin/forestadmin-client'; +import type { OAuthError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; + +import { toAgentTokenClaims } from '@forestadmin/agent-client'; +import { GatewayApiKeyResolveError } from '@forestadmin/forestadmin-client'; +import { + InsufficientScopeError, + InvalidTokenError, + ServerError, +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import crypto from 'crypto'; +import jsonwebtoken from 'jsonwebtoken'; + +export const SERVICE_ACCOUNT_SCOPES = ['mcp:read', 'mcp:write', 'mcp:action']; + +const AGENT_TOKEN_TTL_SECONDS = 5 * 60; +const POSITIVE_TTL_MS = 60_000; +const NEGATIVE_TTL_MS = 10_000; +const MAX_ENTRIES = 10_000; +const NEGATIVE_CACHE_STATUSES = new Set([401, 403]); +const PLAN_FEATURE_MISSING = 'plan_feature_missing'; + +export interface AuthenticatedGatewayApiKey { + identity: ResolvedGatewayApiKeyIdentity & { saasAccessToken: string }; + agentToken: string; + expiresAt: number; +} + +export interface GatewayApiKeyAuthenticatorOptions { + client: Pick; + authSecret: string; + now?: () => number; +} + +type CacheEntry = + | { kind: 'positive'; identity: AuthenticatedGatewayApiKey['identity']; expiresAt: number } + | { kind: 'negative'; error: OAuthError; expiresAt: number }; + +function toOAuthError(error: GatewayApiKeyResolveError): OAuthError { + if (error.status === 401) { + return new InvalidTokenError('Invalid or revoked service account credential'); + } + + if (error.status === 403 && error.code === PLAN_FEATURE_MISSING) { + return new InsufficientScopeError("The project's plan does not include the Gateway MCP"); + } + + if (error.status === 403) { + return new InsufficientScopeError('This service account is not allowed on this environment'); + } + + return new ServerError('Unable to resolve the service account credential'); +} + +function mapResolveError(error: GatewayApiKeyResolveError): OAuthError { + return Object.assign(toOAuthError(error), { cause: error }); +} + +export default class GatewayApiKeyAuthenticator { + private readonly client: Pick; + private readonly authSecret: string; + private readonly now: () => number; + private readonly cache = new Map(); + + constructor({ client, authSecret, now = Date.now }: GatewayApiKeyAuthenticatorOptions) { + this.client = client; + this.authSecret = authSecret; + this.now = now; + } + + async authenticate(parsedKey: ParsedGatewayApiKey): Promise { + const hash = crypto + .createHash('sha256') + .update(`${parsedKey.keyId}:${parsedKey.secret}`) + .digest('hex'); + const cached = this.liveEntry(hash); + + if (cached?.kind === 'negative') throw cached.error; + if (cached?.kind === 'positive') return this.mint(cached.identity); + + let identity: ResolvedGatewayApiKeyIdentity; + + try { + identity = await this.client.resolve(parsedKey); + } catch (error) { + if (!(error instanceof GatewayApiKeyResolveError)) throw error; + + const mapped = mapResolveError(error); + + if (error.status !== undefined && NEGATIVE_CACHE_STATUSES.has(error.status)) { + this.store(hash, { + kind: 'negative', + error: mapped, + expiresAt: this.expiry(NEGATIVE_TTL_MS), + }); + } + + throw mapped; + } + + if (!identity.saasAccessToken) { + throw new ServerError('The service account credential resolved without a Forest token'); + } + + const complete = { ...identity, saasAccessToken: identity.saasAccessToken }; + this.store(hash, { + kind: 'positive', + identity: complete, + expiresAt: this.expiry(POSITIVE_TTL_MS), + }); + + return this.mint(complete); + } + + private mint(identity: AuthenticatedGatewayApiKey['identity']): AuthenticatedGatewayApiKey { + const { user, renderingId } = identity; + const expiresAt = Math.floor(this.now() / 1000) + AGENT_TOKEN_TTL_SECONDS; + const agentToken = jsonwebtoken.sign( + { + ...toAgentTokenClaims({ + id: user.id, + email: user.email, + firstName: user.firstName ?? '', + lastName: user.lastName ?? '', + team: user.team, + renderingId, + tags: Object.fromEntries(user.tags.map(({ key, value }) => [key, value])), + permissionLevel: user.permissionLevel, + }), + exp: expiresAt, + }, + this.authSecret, + { algorithm: 'HS256' }, + ); + + return { identity, agentToken, expiresAt }; + } + + private expiry(ttlMs: number): number { + return this.now() + ttlMs; + } + + private liveEntry(hash: string): CacheEntry | undefined { + const entry = this.cache.get(hash); + + if (!entry) return undefined; + + if (this.now() >= entry.expiresAt) { + this.cache.delete(hash); + + return undefined; + } + + return entry; + } + + private store(hash: string, entry: CacheEntry): void { + this.cache.delete(hash); + + if (this.cache.size >= MAX_ENTRIES) { + const oldest = this.cache.keys().next().value; + if (oldest !== undefined) this.cache.delete(oldest); + } + + this.cache.set(hash, entry); + } +} diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index a4ed96bfdd..9c63bf97de 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -3,22 +3,35 @@ import type { TokenTtlOptions } from '../src/utils/token-ttl'; import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth'; import type { Response } from 'express'; -import createForestAdminClient from '@forestadmin/forestadmin-client'; +import createForestAdminClient, { + GatewayApiKeyClient, + parseGatewayApiKey, +} from '@forestadmin/forestadmin-client'; import { + InsufficientScopeError, InvalidClientError, InvalidTokenError, + ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import jsonwebtoken from 'jsonwebtoken'; import forestServerIssueTokenSchema from './helpers/forest-server-issue-token-schema'; import MockServer from './test-utils/mock-server'; import ForestOAuthProvider from '../src/forest-oauth-provider'; +import GatewayApiKeyAuthenticator from '../src/gateway-api-key-authenticator'; + +const mockAuthenticate = jest.fn(); jest.mock('jsonwebtoken'); jest.mock('@forestadmin/forestadmin-client', () => ({ ...jest.createMockFromModule('@forestadmin/forestadmin-client'), isClientAllowed: jest.requireActual('@forestadmin/forestadmin-client').isClientAllowed, })); +jest.mock('../src/gateway-api-key-authenticator', () => ({ + __esModule: true, + ...jest.requireActual('../src/gateway-api-key-authenticator'), + default: jest.fn().mockImplementation(() => ({ authenticate: mockAuthenticate })), +})); const mockCreateForestAdminClient = createForestAdminClient as jest.MockedFunction< typeof createForestAdminClient @@ -1109,6 +1122,156 @@ describe('ForestOAuthProvider', () => { }); }); + describe('verifyAccessToken with a service account credential', () => { + const PARSED = { keyId: 'a'.repeat(16), secret: 'b'.repeat(64) }; + const CREDENTIAL = `fgw_${PARSED.keyId}_${PARSED.secret}`; + const EXPIRES_AT = Math.floor(Date.now() / 1000) + 300; + const AUTHENTICATED = { + identity: { + user: { + id: 42, + email: 'bot@forest.local', + firstName: 'Bot', + lastName: null, + team: 'Operations', + tags: [], + permissionLevel: 'user', + }, + renderingId: 7, + saasAccessToken: 'saas-token', + }, + agentToken: 'agent-token', + expiresAt: EXPIRES_AT, + }; + + beforeEach(() => { + (parseGatewayApiKey as jest.Mock).mockImplementation(raw => + raw === CREDENTIAL ? PARSED : null, + ); + mockAuthenticate.mockReset(); + (jsonwebtoken.verify as jest.Mock).mockClear(); + }); + + afterEach(() => { + (parseGatewayApiKey as jest.Mock).mockReset(); + }); + + it('should resolve credentials for the mcp service with the env secret', () => { + createProvider('https://custom.forestadmin.com'); + + expect(GatewayApiKeyClient).toHaveBeenLastCalledWith({ + forestServerUrl: 'https://custom.forestadmin.com', + envSecret: TEST_ENV_SECRET, + service: 'mcp', + }); + expect(GatewayApiKeyAuthenticator).toHaveBeenLastCalledWith({ + client: jest.mocked(GatewayApiKeyClient).mock.instances.at(-1), + authSecret: TEST_AUTH_SECRET, + }); + }); + + it('should build the auth info of the service account from the credential', async () => { + mockAuthenticate.mockResolvedValue(AUTHENTICATED); + + const result = await createProvider().verifyAccessToken(CREDENTIAL); + + expect(mockAuthenticate).toHaveBeenCalledWith(PARSED); + expect(result).toEqual({ + token: 'agent-token', + clientId: `service-account-key:${PARSED.keyId}`, + expiresAt: EXPIRES_AT, + scopes: ['mcp:read', 'mcp:write', 'mcp:action'], + extra: { + userId: 42, + email: 'bot@forest.local', + renderingId: 7, + environmentApiEndpoint: undefined, + forestServerToken: 'saas-token', + }, + }); + }); + + it('should not verify the credential as a JWT', async () => { + mockAuthenticate.mockResolvedValue(AUTHENTICATED); + + await createProvider().verifyAccessToken(CREDENTIAL); + + expect(jsonwebtoken.verify).not.toHaveBeenCalled(); + }); + + it('should use agentUrl as the tool callback endpoint when configured', async () => { + mockAuthenticate.mockResolvedValue(AUTHENTICATED); + + const result = await createProvider( + 'https://api.forestadmin.com', + 'http://forest-agent.internal:3310', + ).verifyAccessToken(CREDENTIAL); + + expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe( + 'http://forest-agent.internal:3310', + ); + }); + + it('should propagate a refusal and log it with the key id', async () => { + const refusal = new InsufficientScopeError( + "The project's plan does not include the Gateway MCP", + ); + mockAuthenticate.mockRejectedValue(refusal); + const logger = jest.fn(); + + await expect( + createProvider( + 'https://api.forestadmin.com', + undefined, + undefined, + logger, + ).verifyAccessToken(CREDENTIAL), + ).rejects.toBe(refusal); + expect(logger).toHaveBeenCalledWith( + 'Error', + expect.stringContaining(`Service account credential ${PARSED.keyId} refused`), + ); + }); + + it('should log the cause of a refusal', async () => { + const refusal = Object.assign( + new ServerError('Unable to resolve the service account credential'), + { + cause: new Error('Gateway API key resolve failed (status 404)'), + }, + ); + mockAuthenticate.mockRejectedValue(refusal); + const logger = jest.fn(); + + await createProvider('https://api.forestadmin.com', undefined, undefined, logger) + .verifyAccessToken(CREDENTIAL) + .catch(() => undefined); + + expect(logger).toHaveBeenCalledWith( + 'Error', + expect.stringContaining( + 'Unable to resolve the service account credential (Error: Gateway API key resolve failed (status 404))', + ), + ); + }); + + it('should verify a token that is not a credential as an OAuth access token', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + id: 1, + email: 'user@example.com', + renderingId: 2, + serverToken: 'forest-server-token', + scopes: ['mcp:read'], + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + const result = await createProvider().verifyAccessToken('valid-access-token'); + + expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(result.token).toBe('valid-access-token'); + }); + }); + describe('tokenTtl', () => { const NOW_MS = Date.UTC(2026, 0, 1); const nowInSeconds = Math.floor(NOW_MS / 1000); diff --git a/packages/mcp-server/test/gateway-api-key-authenticator.test.ts b/packages/mcp-server/test/gateway-api-key-authenticator.test.ts new file mode 100644 index 0000000000..a266be3e85 --- /dev/null +++ b/packages/mcp-server/test/gateway-api-key-authenticator.test.ts @@ -0,0 +1,249 @@ +import { GatewayApiKeyResolveError } from '@forestadmin/forestadmin-client'; +import { + InsufficientScopeError, + InvalidTokenError, + ServerError, +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import jsonwebtoken from 'jsonwebtoken'; + +import GatewayApiKeyAuthenticator from '../src/gateway-api-key-authenticator'; + +const AUTH_SECRET = 'auth-secret'; +const PARSED = { keyId: 'a'.repeat(16), secret: 'b'.repeat(64) }; +const OTHER_PARSED = { keyId: 'c'.repeat(16), secret: 'd'.repeat(64) }; +const NOW = 1_700_000_000_000; + +const IDENTITY = { + user: { + id: 42, + email: 'bot@forest.local', + firstName: 'Bot', + lastName: null, + team: 'Operations', + tags: [{ key: 'region', value: 'eu' }], + permissionLevel: 'user', + }, + renderingId: 7, + saasAccessToken: 'saas-token', +}; + +function setup({ now = (): number => NOW }: { now?: () => number } = {}) { + const resolve = jest.fn(); + const authenticator = new GatewayApiKeyAuthenticator({ + client: { resolve }, + authSecret: AUTH_SECRET, + now, + }); + + return { resolve, authenticator }; +} + +describe('GatewayApiKeyAuthenticator', () => { + it('should resolve the parsed key and return the identity', async () => { + const { resolve, authenticator } = setup(); + resolve.mockResolvedValue(IDENTITY); + + const result = await authenticator.authenticate(PARSED); + + expect(resolve).toHaveBeenCalledWith(PARSED); + expect(result.identity).toEqual(IDENTITY); + }); + + it('should mint an agent token in the shape Ruby and Node agents read, signed for five minutes', async () => { + const { resolve, authenticator } = setup(); + resolve.mockResolvedValue(IDENTITY); + + const { agentToken, expiresAt } = await authenticator.authenticate(PARSED); + + expect(expiresAt).toBe(NOW / 1000 + 300); + expect(jsonwebtoken.verify(agentToken, AUTH_SECRET, { ignoreExpiration: true })).toEqual( + expect.objectContaining({ + id: 42, + email: 'bot@forest.local', + firstName: 'Bot', + lastName: '', + team: 'Operations', + renderingId: 7, + tags: [{ key: 'region', value: 'eu' }], + permissionLevel: 'user', + first_name: 'Bot', + last_name: '', + rendering_id: '7', + permission_level: 'user', + exp: NOW / 1000 + 300, + }), + ); + }); + + it('should serve a resolved key from the cache for 60 seconds', async () => { + let now = NOW; + const { resolve, authenticator } = setup({ now: () => now }); + resolve.mockResolvedValue(IDENTITY); + + await authenticator.authenticate(PARSED); + now += 59_999; + await authenticator.authenticate(PARSED); + + expect(resolve).toHaveBeenCalledTimes(1); + + now += 1; + await authenticator.authenticate(PARSED); + + expect(resolve).toHaveBeenCalledTimes(2); + }); + + it('should mint a fresh agent token on a cached resolution', async () => { + let now = NOW; + const { resolve, authenticator } = setup({ now: () => now }); + resolve.mockResolvedValue(IDENTITY); + + const first = await authenticator.authenticate(PARSED); + now += 30_000; + const second = await authenticator.authenticate(PARSED); + + expect(second.expiresAt).toBe(first.expiresAt + 30); + }); + + it('should cache each key separately', async () => { + const { resolve, authenticator } = setup(); + resolve.mockResolvedValue(IDENTITY); + + await authenticator.authenticate(PARSED); + await authenticator.authenticate(OTHER_PARSED); + + expect(resolve).toHaveBeenNthCalledWith(1, PARSED); + expect(resolve).toHaveBeenNthCalledWith(2, OTHER_PARSED); + }); + + it('should evict the oldest key once 10000 keys are cached', async () => { + const { resolve, authenticator } = setup(); + resolve.mockResolvedValue(IDENTITY); + const keyAt = (index: number) => ({ + keyId: index.toString(16).padStart(16, '0'), + secret: 'b'.repeat(64), + }); + + for (let index = 0; index <= 10_000; index += 1) { + // eslint-disable-next-line no-await-in-loop + await authenticator.authenticate(keyAt(index)); + } + + await authenticator.authenticate(keyAt(1)); + expect(resolve).toHaveBeenCalledTimes(10_001); + + await authenticator.authenticate(keyAt(0)); + expect(resolve).toHaveBeenCalledTimes(10_002); + }); + + it('should refuse an unknown or revoked key as an invalid token', async () => { + const { resolve, authenticator } = setup(); + resolve.mockRejectedValue(new GatewayApiKeyResolveError({ status: 401 })); + + const error = await authenticator.authenticate(PARSED).catch(e => e); + + expect(error).toBeInstanceOf(InvalidTokenError); + expect(error.message).toBe('Invalid or revoked service account credential'); + }); + + it('should refuse a project without the Gateway MCP plan feature as insufficient scope', async () => { + const { resolve, authenticator } = setup(); + resolve.mockRejectedValue( + new GatewayApiKeyResolveError({ status: 403, code: 'plan_feature_missing' }), + ); + + const error = await authenticator.authenticate(PARSED).catch(e => e); + + expect(error).toBeInstanceOf(InsufficientScopeError); + expect(error.message).toBe("The project's plan does not include the Gateway MCP"); + }); + + it('should refuse a service account not allowed on the environment as insufficient scope', async () => { + const { resolve, authenticator } = setup(); + resolve.mockRejectedValue( + new GatewayApiKeyResolveError({ status: 403, code: 'forest_identity_not_allowed' }), + ); + + const error = await authenticator.authenticate(PARSED).catch(e => e); + + expect(error).toBeInstanceOf(InsufficientScopeError); + expect(error.message).toBe('This service account is not allowed on this environment'); + }); + + it.each([ + ['a 400', { status: 400 }], + ['a 429', { status: 429, retryAfter: 5 }], + ['a 500', { status: 500 }], + ['an unreachable server', { unreachable: true }], + ])('should report %s as a server error', async (_, params) => { + const { resolve, authenticator } = setup(); + resolve.mockRejectedValue(new GatewayApiKeyResolveError(params)); + + const error = await authenticator.authenticate(PARSED).catch(e => e); + + expect(error).toBeInstanceOf(ServerError); + expect(error.message).toBe('Unable to resolve the service account credential'); + }); + + it.each([401, 403])('should cache a %s refusal for 10 seconds', async status => { + let now = NOW; + const { resolve, authenticator } = setup({ now: () => now }); + resolve.mockRejectedValue(new GatewayApiKeyResolveError({ status })); + + await authenticator.authenticate(PARSED).catch(() => undefined); + now += 9_999; + await authenticator.authenticate(PARSED).catch(() => undefined); + + expect(resolve).toHaveBeenCalledTimes(1); + + now += 1; + await authenticator.authenticate(PARSED).catch(() => undefined); + + expect(resolve).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['a 429', { status: 429 }], + ['a 500', { status: 500 }], + ['an unreachable server', { unreachable: true }], + ])('should not cache %s', async (_, params) => { + const { resolve, authenticator } = setup(); + resolve.mockRejectedValueOnce(new GatewayApiKeyResolveError(params)); + resolve.mockResolvedValueOnce(IDENTITY); + + await authenticator.authenticate(PARSED).catch(() => undefined); + const result = await authenticator.authenticate(PARSED); + + expect(result.identity).toEqual(IDENTITY); + }); + + it.each([401, 403, 500])('should keep the resolve error of a %s as the cause', async status => { + const { resolve, authenticator } = setup(); + const resolveError = new GatewayApiKeyResolveError({ status, code: 'some_code' }); + resolve.mockRejectedValue(resolveError); + + const error = await authenticator.authenticate(PARSED).catch(e => e); + + expect(error.cause).toBe(resolveError); + }); + + it('should rethrow an error that is not a resolve refusal', async () => { + const { resolve, authenticator } = setup(); + const unexpected = new TypeError('boom'); + resolve.mockRejectedValue(unexpected); + + await expect(authenticator.authenticate(PARSED)).rejects.toBe(unexpected); + }); + + it('should refuse and not cache an identity without a Forest token', async () => { + const { resolve, authenticator } = setup(); + resolve.mockResolvedValueOnce({ ...IDENTITY, saasAccessToken: undefined }); + resolve.mockResolvedValueOnce(IDENTITY); + + const error = await authenticator.authenticate(PARSED).catch(e => e); + const result = await authenticator.authenticate(PARSED); + + expect(error).toBeInstanceOf(ServerError); + expect(error.message).toBe('The service account credential resolved without a Forest token'); + expect(result.identity).toEqual(IDENTITY); + }); +}); From 00db7bf8833b8fa9708c8d0893b380e97cb8f4f3 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Mon, 12 Oct 2026 14:42:02 +0200 Subject: [PATCH 07/11] feat(agent-bff): declare the api service and surface the plan refusal (#1985) * feat(agent-bff): declare the api service and surface the plan refusal * fix(agent-bff): log the plan refusal as expected, with its reason * refactor(agent-bff): extract the session access failure mapping --------- Co-authored-by: sim --- .../agent-bff/src/api-key/api-key-error.ts | 6 +- .../auth/forest-server-token-middleware.ts | 42 +++++++----- .../src/oauth/forest-server-client.ts | 4 +- packages/agent-bff/src/oauth/oauth-error.ts | 10 +++ .../src/oauth/oauth-exchange-error.ts | 4 +- packages/agent-bff/src/oauth/oauth-routes.ts | 17 +++-- .../agent-bff/src/oauth/session-lifecycle.ts | 17 ++++- .../action/action-routes-activity-log.test.ts | 53 ++++++++++++++- .../activity-logs-creator.test.ts | 29 ++++++++ .../test/ai/ai-routes-middleware.test.ts | 32 +++++++++ .../forest-server-token-middleware.test.ts | 29 ++++++++ ...erver-oauth-route-validator-issue-token.ts | 4 ++ .../test/oauth/forest-server-client.test.ts | 32 +++++++++ .../forest-server-token-contract.test.ts | 40 ++++++++++- .../agent-bff/test/oauth/oauth-routes.test.ts | 68 +++++++++++++++++++ .../test/oauth/session-lifecycle.test.ts | 28 ++++++++ 16 files changed, 386 insertions(+), 29 deletions(-) diff --git a/packages/agent-bff/src/api-key/api-key-error.ts b/packages/agent-bff/src/api-key/api-key-error.ts index b5144ee180..5b3cbb2ca2 100644 --- a/packages/agent-bff/src/api-key/api-key-error.ts +++ b/packages/agent-bff/src/api-key/api-key-error.ts @@ -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); } diff --git a/packages/agent-bff/src/auth/forest-server-token-middleware.ts b/packages/agent-bff/src/auth/forest-server-token-middleware.ts index 0ba2792d21..0f4325d844 100644 --- a/packages/agent-bff/src/auth/forest-server-token-middleware.ts +++ b/packages/agent-bff/src/auth/forest-server-token-middleware.ts @@ -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, @@ -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); } } diff --git a/packages/agent-bff/src/oauth/forest-server-client.ts b/packages/agent-bff/src/oauth/forest-server-client.ts index 899948560f..ce2cf74070 100644 --- a/packages/agent-bff/src/oauth/forest-server-client.ts +++ b/packages/agent-bff/src/oauth/forest-server-client.ts @@ -161,7 +161,7 @@ export default class ForestServerClient { payload: Record, isInitialExchange: boolean, ): Promise { - const response = await fetchWithTimeout(this.url('/oauth/token'), { + const response = await fetchWithTimeout(this.url('/oauth/token?service=api'), { method: 'POST', headers: { ...DEFAULT_HEADERS, 'forest-secret-key': this.envSecret }, body: JSON.stringify(payload), @@ -171,10 +171,12 @@ export default class ForestServerClient { const errorBody = (await response.json().catch(() => ({}))) as { error?: string; error_description?: string; + reason?: string; }; throw new OAuthExchangeError( errorBody.error || 'server_error', errorBody.error_description || 'Failed to exchange token with the Forest server', + errorBody.reason, ); } diff --git a/packages/agent-bff/src/oauth/oauth-error.ts b/packages/agent-bff/src/oauth/oauth-error.ts index 5074e5ccce..6cad9e93d3 100644 --- a/packages/agent-bff/src/oauth/oauth-error.ts +++ b/packages/agent-bff/src/oauth/oauth-error.ts @@ -1,3 +1,5 @@ +import { PLAN_FEATURE_MISSING_MESSAGE } from '../api-key/api-key-error'; + export class OAuthRequestError extends Error { readonly status: number; readonly type: string; @@ -52,3 +54,11 @@ export function sessionInvalidated(message: string): OAuthRequestError { export function serverError(message: string, cause?: unknown): OAuthRequestError { return new OAuthRequestError(502, 'server_error', message, cause); } + +export function planFeatureMissing(): OAuthRequestError { + return new OAuthRequestError(403, 'plan_feature_missing', PLAN_FEATURE_MISSING_MESSAGE); +} + +export function isPlanFeatureMissingRefusal(error: { reason?: string }): boolean { + return error.reason === 'plan_feature_missing'; +} diff --git a/packages/agent-bff/src/oauth/oauth-exchange-error.ts b/packages/agent-bff/src/oauth/oauth-exchange-error.ts index 42e98c5ee8..e76607a708 100644 --- a/packages/agent-bff/src/oauth/oauth-exchange-error.ts +++ b/packages/agent-bff/src/oauth/oauth-exchange-error.ts @@ -1,9 +1,11 @@ export default class OAuthExchangeError extends Error { readonly error: string; + readonly reason?: string; - constructor(error: string, description: string) { + constructor(error: string, description: string, reason?: string) { super(description); this.name = 'OAuthExchangeError'; this.error = error; + this.reason = reason; } } diff --git a/packages/agent-bff/src/oauth/oauth-routes.ts b/packages/agent-bff/src/oauth/oauth-routes.ts index 728567c702..3ff6eaf4ff 100644 --- a/packages/agent-bff/src/oauth/oauth-routes.ts +++ b/packages/agent-bff/src/oauth/oauth-routes.ts @@ -18,6 +18,8 @@ import { invalidClient, invalidGrant, invalidRequest, + isPlanFeatureMissingRefusal, + planFeatureMissing, serverError, sessionExpired, sessionInvalidated, @@ -112,9 +114,11 @@ function assertClientIsAllowed( throw invalidClient(CLIENT_NOT_APPROVED_MESSAGE); } -function toSafeExchangeError(saasError: string): OAuthRequestError { - if (SAFE_EXCHANGE_ERRORS.has(saasError)) { - return new OAuthRequestError(400, saasError, 'Authorization code exchange failed'); +function toSafeExchangeError(saasError: OAuthExchangeError): OAuthRequestError { + if (isPlanFeatureMissingRefusal(saasError)) return planFeatureMissing(); + + if (SAFE_EXCHANGE_ERRORS.has(saasError.error)) { + return new OAuthRequestError(400, saasError.error, 'Authorization code exchange failed'); } return new OAuthRequestError( @@ -297,8 +301,11 @@ async function exchangeForServerTokens( return await options.serverClient.exchangeCode(request); } catch (error) { if (error instanceof OAuthExchangeError) { - options.logger('Warn', 'Forest server code exchange rejected', { saasError: error.error }); - throw toSafeExchangeError(error.error); + options.logger('Warn', 'Forest server code exchange rejected', { + saasError: error.error, + reason: error.reason, + }); + throw toSafeExchangeError(error); } throw error; diff --git a/packages/agent-bff/src/oauth/session-lifecycle.ts b/packages/agent-bff/src/oauth/session-lifecycle.ts index d28dfbaeab..b4259faafa 100644 --- a/packages/agent-bff/src/oauth/session-lifecycle.ts +++ b/packages/agent-bff/src/oauth/session-lifecycle.ts @@ -5,7 +5,12 @@ import type { Logger } from '../ports/logger-port'; import jsonwebtoken from 'jsonwebtoken'; import { OAuthExchangeError } from './forest-server-client'; -import { serverError, sessionExpired } from './oauth-error'; +import { + isPlanFeatureMissingRefusal, + planFeatureMissing, + serverError, + sessionExpired, +} from './oauth-error'; export interface EnsureFreshServerAccessParams { sid: string; @@ -54,6 +59,16 @@ async function refreshAndPersist( clientId: session.clientId, }); } catch (error) { + if (error instanceof OAuthExchangeError && isPlanFeatureMissingRefusal(error)) { + logger( + 'Warn', + 'The Forest server refused the session refresh: the plan lacks the Gateway API', + sessionContext, + ); + + throw planFeatureMissing(); + } + if (error instanceof OAuthExchangeError && CLIENT_ERROR_CODES.has(error.error)) { logger('Warn', 'The Forest server rejected the session refresh', { ...sessionContext, diff --git a/packages/agent-bff/test/action/action-routes-activity-log.test.ts b/packages/agent-bff/test/action/action-routes-activity-log.test.ts index bbaa72f1c5..9719e70d28 100644 --- a/packages/agent-bff/test/action/action-routes-activity-log.test.ts +++ b/packages/agent-bff/test/action/action-routes-activity-log.test.ts @@ -1,23 +1,29 @@ import type { AgentActionClient } from '../../src/action/agent-action-client'; import type { ActivityLogWriter } from '../../src/activity-log/activity-log-writer'; +import type ForestServerClient from '../../src/oauth/forest-server-client'; +import type { SessionStore } from '../../src/oauth/session-store'; import type { Logger } from '../../src/ports/logger-port'; import type { Middleware } from 'koa'; import { ActionRequiresApprovalError } from '@forestadmin/agent-client'; import { HttpError } from '@forestadmin/forestadmin-client'; import { bodyParser } from '@koa/bodyparser'; +import jsonwebtoken from 'jsonwebtoken'; import Koa from 'koa'; import request from 'supertest'; import createActionRoutesMiddleware from '../../src/action/action-routes-middleware'; import { createHttpTransport } from '../../src/agent/agent-transport'; +import createForestServerTokenMiddleware from '../../src/auth/forest-server-token-middleware'; import createErrorMiddleware from '../../src/http/error-middleware'; +import OAuthExchangeError from '../../src/oauth/oauth-exchange-error'; import { TIMEZONE, clientOf, makeAction, readModel, storeOf } from '../helpers/action-routes'; import { ACTIVITY_LOG_ID, ACTIVITY_LOG_INDEX, API_KEY_SERVER_TOKEN, RENDERING_ID, + SESSION_ID, activityLogsOf, apiKeyCredentials, fakeActivityLogsService, @@ -33,11 +39,13 @@ function buildApp({ client, credentials = apiKeyCredentials(), saasAccessToken, + tokenStep = forestServerTokenStep(saasAccessToken), }: { service: ReturnType; client: AgentActionClient; credentials?: Middleware; saasAccessToken?: string; + tokenStep?: Middleware; }): { app: Koa; activityLogs: ActivityLogWriter } { const activityLogs = activityLogsOf(service, noopLogger); const app = new Koa(); @@ -45,7 +53,7 @@ function buildApp({ app.use(createErrorMiddleware({ logger: noopLogger })); app.use(bodyParser()); app.use(credentials); - app.use(forestServerTokenStep(saasAccessToken)); + app.use(tokenStep); app.use(async (ctx, next) => { ctx.state.timezone = TIMEZONE; ctx.state.agentToken = 'agent-jwt'; @@ -287,6 +295,49 @@ describe('action routes activity log', () => { expect(service.createMcpActivityLog).not.toHaveBeenCalled(); }); + it('should refuse with 403 plan_feature_missing when the Forest server refuses the session refresh on the plan', async () => { + const service = fakeActivityLogsService(); + const loadAction = jest.fn(async () => executingAction()); + const store = { + get: (sid: string) => + sid === SESSION_ID + ? { + saasAccessToken: jsonwebtoken.sign({}, 'session-secret', { expiresIn: '-1s' }), + clientId: 'client-1', + } + : undefined, + getSaasRefreshToken: () => 'refresh-token', + } as unknown as SessionStore; + const serverClient = { + refreshServerToken: async () => { + throw new OAuthExchangeError('access_denied', 'no Gateway API', 'plan_feature_missing'); + }, + } as unknown as ForestServerClient; + const { app } = buildApp({ + service, + client: clientOf(executingAction(), loadAction as jest.Mock), + credentials: oauthCredentials(), + tokenStep: createForestServerTokenMiddleware({ + session: { store, serverClient }, + logger: noopLogger, + }), + }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + error: { + type: 'plan_feature_missing', + status: 403, + message: "The project's plan does not include the Gateway API.", + }, + }); + expect(loadAction).not.toHaveBeenCalled(); + }); + it('should use the session token when the caller carries an oauth session', async () => { const service = fakeActivityLogsService(); const saasAccessToken = sessionAccessToken(); diff --git a/packages/agent-bff/test/activity-log/activity-logs-creator.test.ts b/packages/agent-bff/test/activity-log/activity-logs-creator.test.ts index 2aa5250df0..72aed35b95 100644 --- a/packages/agent-bff/test/activity-log/activity-logs-creator.test.ts +++ b/packages/agent-bff/test/activity-log/activity-logs-creator.test.ts @@ -8,6 +8,7 @@ import createPendingActivityLog, { markActivityLog, } from '../../src/activity-log/activity-logs-creator'; import { AUDIT_RETRY_AFTER_SECONDS, auditUnavailable } from '../../src/http/bff-local-errors'; +import { planFeatureMissing } from '../../src/oauth/oauth-error'; import { ACTIVITY_LOG_ID, ACTIVITY_LOG_INDEX, @@ -106,6 +107,34 @@ describe('activity logs creator', () => { }, ); }); + + it('should serve a read unaudited when the plan lacks the Gateway API', async () => { + const service = fakeActivityLogsService(); + + const pending = await createPendingActivityLog({ + ctx: ctxRejectingCredentials(planFeatureMissing()), + service, + action: 'index', + logger: loggerSpy(), + }); + + expect(pending).toBeNull(); + expect(service.createMcpActivityLog).not.toHaveBeenCalled(); + }); + + it('should refuse a write with plan_feature_missing when the plan lacks the Gateway API', async () => { + const service = fakeActivityLogsService(); + + await expect( + createPendingActivityLog({ + ctx: ctxRejectingCredentials(planFeatureMissing()), + service, + action: 'action', + logger: loggerSpy(), + }), + ).rejects.toMatchObject({ status: 403, type: 'plan_feature_missing' }); + expect(service.createMcpActivityLog).not.toHaveBeenCalled(); + }); }); describe('when the deployment mints no credential to write the log with', () => { diff --git a/packages/agent-bff/test/ai/ai-routes-middleware.test.ts b/packages/agent-bff/test/ai/ai-routes-middleware.test.ts index 88f4734b90..bf18445ca1 100644 --- a/packages/agent-bff/test/ai/ai-routes-middleware.test.ts +++ b/packages/agent-bff/test/ai/ai-routes-middleware.test.ts @@ -13,6 +13,7 @@ import createAiRoutesMiddleware, { AI_QUERY_ROUTE } from '../../src/ai/ai-routes import { AI_BODY_LIMIT } from '../../src/http/body-limit'; import createErrorMiddleware from '../../src/http/error-middleware'; import EnvironmentFetchError from '../../src/oauth/environment-fetch-error'; +import OAuthExchangeError from '../../src/oauth/oauth-exchange-error'; import { restoreFetchAfterEach, stubFetch } from '../helpers/fetch-stub'; const SAAS_ACCESS_TOKEN = 'saas-access-token'; @@ -181,6 +182,37 @@ describe('createAiRoutesMiddleware', () => { }); }); + describe('when the Forest server refuses the session refresh because the plan lacks the Gateway API', () => { + it('should answer 403 plan_feature_missing with the nested body and never reach the upstream', async () => { + const store = makeSessionStore({ + saasAccessToken: 'expired.token.value', + clientId: 'client-1', + }); + const serverClient = { + refreshServerToken: jest + .fn() + .mockRejectedValue( + new OAuthExchangeError('access_denied', 'no Gateway API', 'plan_feature_missing'), + ), + } as unknown as ForestServerClient; + (store.getSaasRefreshToken as jest.Mock).mockReturnValue('refresh-token'); + const query = jest.fn(); + const { app } = makeApp({ query, store, serverClient }); + + const response = await request(app.callback()).post(AI_QUERY_ROUTE).send({ messages: [] }); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + error: { + type: 'plan_feature_missing', + status: 403, + message: "The project's plan does not include the Gateway API.", + }, + }); + expect(query).not.toHaveBeenCalled(); + }); + }); + describe('when the session carries no usable rendering', () => { it('should answer 401 rather than send a bogus forest-rendering-id upstream', async () => { const { app, query } = makeApp({ renderingId: '0' }); diff --git a/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts b/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts index 39c9016b95..3d2c979482 100644 --- a/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts +++ b/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts @@ -191,6 +191,35 @@ describe('forest server token middleware', () => { ); }); + it('should refuse with plan_feature_missing, not audit_unavailable, when the plan lacks the Gateway API', async () => { + const store = { + get: () => ({ saasAccessToken: expiredAccessToken(), clientId: 'client-1' }), + getSaasRefreshToken: () => 'refresh-token', + } as unknown as SessionStore; + const serverClient = { + refreshServerToken: async () => { + throw new OAuthExchangeError('access_denied', 'no Gateway API', 'plan_feature_missing'); + }, + } as unknown as ForestServerClient; + const ctx = contextOf({ + authMode: 'oauth', + principal: { sid: SESSION_ID, rendering_id: String(RENDERING_ID) }, + }); + const logger = jest.fn(); + const middleware = createForestServerTokenMiddleware({ + session: { store, serverClient }, + logger, + }); + await middleware(ctx, async () => undefined); + + await expect(resolveForestServerToken(ctx)).rejects.toMatchObject({ + status: 403, + type: 'plan_feature_missing', + message: "The project's plan does not include the Gateway API.", + }); + expect(logger).not.toHaveBeenCalledWith('Error', expect.anything(), expect.anything()); + }); + it('should refuse with session_expired when the deployment carries no session store', async () => { const ctx = contextOf({ authMode: 'oauth', diff --git a/packages/agent-bff/test/oauth/fixtures/forestadmin-server-oauth-route-validator-issue-token.ts b/packages/agent-bff/test/oauth/fixtures/forestadmin-server-oauth-route-validator-issue-token.ts index e181ed45b6..3a9f27485a 100644 --- a/packages/agent-bff/test/oauth/fixtures/forestadmin-server-oauth-route-validator-issue-token.ts +++ b/packages/agent-bff/test/oauth/fixtures/forestadmin-server-oauth-route-validator-issue-token.ts @@ -1,5 +1,9 @@ import Joi from 'joi'; +export const issueTokenQuerySchema = Joi.object({ + service: Joi.string().valid('api', 'mcp'), +}).unknown(); + const issueTokenBodySchema = Joi.object({ client_id: Joi.string().required(), grant_type: Joi.string().valid('authorization_code', 'refresh_token').required(), diff --git a/packages/agent-bff/test/oauth/forest-server-client.test.ts b/packages/agent-bff/test/oauth/forest-server-client.test.ts index c1fb9e1a36..36ca8ba1c6 100644 --- a/packages/agent-bff/test/oauth/forest-server-client.test.ts +++ b/packages/agent-bff/test/oauth/forest-server-client.test.ts @@ -71,6 +71,38 @@ describe('ForestServerClient', () => { ).rejects.toMatchObject({ error: 'invalid_grant' }); }); + it('should carry the reason of a refused exchange', async () => { + mockFetchOnce({ + ok: false, + status: 400, + json: async () => ({ + error: 'access_denied', + error_description: 'no Gateway API', + reason: 'plan_feature_missing', + }), + }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect( + client.exchangeCode({ code: 'c', codeVerifier: 'v', redirectUri: 'r', clientId: 'x' }), + ).rejects.toMatchObject({ + error: 'access_denied', + reason: 'plan_feature_missing', + message: 'no Gateway API', + }); + }); + + it('should leave the reason undefined when the refusal carries none', async () => { + mockFetchOnce({ ok: false, status: 400, json: async () => ({ error: 'access_denied' }) }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect( + client.exchangeCode({ code: 'c', codeVerifier: 'v', redirectUri: 'r', clientId: 'x' }), + ).rejects.toMatchObject({ error: 'access_denied', reason: undefined }); + }); + it('should throw when the exchange response omits the refresh token', async () => { mockFetchOnce({ ok: true, json: async () => ({ access_token: saasAccessToken(17) }) }); diff --git a/packages/agent-bff/test/oauth/forest-server-token-contract.test.ts b/packages/agent-bff/test/oauth/forest-server-token-contract.test.ts index 6ebfae9766..de35cbc39c 100644 --- a/packages/agent-bff/test/oauth/forest-server-token-contract.test.ts +++ b/packages/agent-bff/test/oauth/forest-server-token-contract.test.ts @@ -1,6 +1,8 @@ import jsonwebtoken from 'jsonwebtoken'; -import issueTokenBodySchema from './fixtures/forestadmin-server-oauth-route-validator-issue-token'; +import issueTokenBodySchema, { + issueTokenQuerySchema, +} from './fixtures/forestadmin-server-oauth-route-validator-issue-token'; import ForestServerClient from '../../src/oauth/forest-server-client'; jest.mock('@forestadmin/forestadmin-client', () => ({ @@ -27,6 +29,10 @@ function sentBody(fetchMock: jest.Mock): unknown { return JSON.parse(fetchMock.mock.calls[0][1].body); } +function sentUrl(fetchMock: jest.Mock): URL { + return new URL(fetchMock.mock.calls[0][0]); +} + describe('Forest server /oauth/token contract', () => { const client = new ForestServerClient({ forestServerUrl: 'https://api.forestadmin.com', @@ -54,6 +60,38 @@ describe('Forest server /oauth/token contract', () => { expect(issueTokenBodySchema.validate(sentBody(fetchMock)).error).toBeUndefined(); }); + it.each([ + [ + 'refresh', + (c: ForestServerClient) => c.refreshServerToken({ refreshToken: 'R1', clientId: 'client-1' }), + ], + [ + 'authorization code', + (c: ForestServerClient) => + c.exchangeCode({ + code: 'code-1', + codeVerifier: 'verifier-1', + redirectUri: 'http://localhost/callback', + clientId: 'client-1', + }), + ], + ])( + 'should declare service=api in the query of a %s grant, never in the body', + async (_, send) => { + const fetchMock = captureTokenRequestBody(); + + await send(client); + const url = sentUrl(fetchMock); + const query = Object.fromEntries(url.searchParams); + + expect(url.pathname).toBe('/oauth/token'); + expect(query).toEqual({ service: 'api' }); + expect(issueTokenQuerySchema.validate(query).error).toBeUndefined(); + expect(sentBody(fetchMock)).not.toHaveProperty('service'); + expect(issueTokenBodySchema.validate(sentBody(fetchMock)).error).toBeUndefined(); + }, + ); + it('should be rejected by the Forest server schema when client_id is missing', () => { const { error } = issueTokenBodySchema.validate({ grant_type: 'refresh_token', diff --git a/packages/agent-bff/test/oauth/oauth-routes.test.ts b/packages/agent-bff/test/oauth/oauth-routes.test.ts index 11b5eaa242..7f018b0120 100644 --- a/packages/agent-bff/test/oauth/oauth-routes.test.ts +++ b/packages/agent-bff/test/oauth/oauth-routes.test.ts @@ -599,6 +599,47 @@ describe('oauth-routes POST /oauth/token', () => { }); }); + describe('when the Forest server refuses the exchange because the plan lacks the Gateway API', () => { + it('should answer 403 plan_feature_missing with the flat OAuth body and keep the code claimed', async () => { + const exchangeCode = jest.fn(async () => { + throw new OAuthExchangeError('access_denied', 'no Gateway API', 'plan_feature_missing'); + }); + const { app, logs } = buildApp(stubServerClient({ exchangeCode })); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + const replay = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + error: 'plan_feature_missing', + error_description: "The project's plan does not include the Gateway API.", + }); + expect(replay.status).toBe(400); + expect(replay.body.error).toBe('invalid_grant'); + expect(exchangeCode).toHaveBeenCalledTimes(1); + expect(logs).toContainEqual({ + level: 'Warn', + message: 'Forest server code exchange rejected', + context: { saasError: 'access_denied', reason: 'plan_feature_missing' }, + }); + }); + + it('should keep the 502 server_error for an access_denied carrying another reason', async () => { + const { app } = buildApp( + stubServerClient({ + exchangeCode: async () => { + throw new OAuthExchangeError('access_denied', 'other', 'something_else'); + }, + }), + ); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(502); + expect(response.body.error).toBe('server_error'); + }); + }); + describe('when an unsupported grant_type is used', () => { it('should reject a grant_type that is neither authorization_code nor refresh_token', async () => { const { app } = buildApp(stubServerClient()); @@ -882,6 +923,33 @@ describe('oauth-routes POST /oauth/token refresh_token grant', () => { }); }); + describe('when the Forest server refuses the refresh because the plan lacks the Gateway API', () => { + it('should answer 403 plan_feature_missing and keep the refresh token usable once Forest accepts again', async () => { + let refused = true; + const refreshServerToken = jest.fn(async () => { + if (refused) { + throw new OAuthExchangeError('access_denied', 'no Gateway API', 'plan_feature_missing'); + } + + return decodableServerTokens(); + }); + const { app } = buildApp(stubServerClient({ refreshServerToken })); + const refreshToken = await login(app); + + const refusedResponse = await refresh(app, refreshToken); + refused = false; + const restoredResponse = await refresh(app, refreshToken); + + expect(refusedResponse.status).toBe(403); + expect(refusedResponse.body).toEqual({ + error: 'plan_feature_missing', + error_description: "The project's plan does not include the Gateway API.", + }); + expect(restoredResponse.status).toBe(200); + expect(restoredResponse.body.refresh_token).not.toBe(refreshToken); + }); + }); + describe('when the underlying SaaS refresh fails transiently', () => { it('should not rotate the presented token, so a retry after the blip succeeds', async () => { let attempts = 0; diff --git a/packages/agent-bff/test/oauth/session-lifecycle.test.ts b/packages/agent-bff/test/oauth/session-lifecycle.test.ts index ad8947233e..d2e11db99a 100644 --- a/packages/agent-bff/test/oauth/session-lifecycle.test.ts +++ b/packages/agent-bff/test/oauth/session-lifecycle.test.ts @@ -259,6 +259,34 @@ describe('ensureFreshServerAccess', () => { }); }); + describe('when the Forest server refuses the refresh because the plan lacks the Gateway API', () => { + it('should throw 403 plan_feature_missing and keep the session and its refresh token', async () => { + const store = buildStore(); + const { sid } = store.create({ + saasAccessToken: accessToken(-10), + saasRefreshToken: 'R1', + renderingId: 17, + userId: 42, + clientId: 'client-1', + }); + const client = { + refreshServerToken: jest.fn(async () => { + throw new OAuthExchangeError('access_denied', 'no Gateway API', 'plan_feature_missing'); + }), + } as unknown as ForestServerClient; + + await expect( + ensureFreshServerAccess({ sid, store, serverClient: client, logger }), + ).rejects.toMatchObject({ + status: 403, + type: 'plan_feature_missing', + message: "The project's plan does not include the Gateway API.", + }); + expect(store.get(sid)).toBeDefined(); + expect(store.getSaasRefreshToken(sid)).toBe('R1'); + }); + }); + describe('when the Forest server rejects the refresh token', () => { it('should throw session_expired so the client re-authenticates', async () => { const store = buildStore(); From b97175b693a4aa75d47bfdf98e1c7c5d0e0ecc57 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 12 Oct 2026 15:16:03 +0200 Subject: [PATCH 08/11] fix(agent): validate the gateway allowlist early and claim mcp discovery while down --- packages/agent/src/agent.ts | 18 ++++--- packages/agent/src/gateway.ts | 14 ++---- .../agent/test/agent-gateway-wiring.test.ts | 48 +++++++++++++++---- 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index fcb6286ad7..b2a6c7c5e6 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -23,6 +23,7 @@ import type { ForestSchema } from '@forestadmin/forestadmin-client'; import type { FileUploadsOptions, TokenTtlOptions, ToolName } from '@forestadmin/mcp-server'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; +import { normalizeDomainList } from '@forestadmin/forestadmin-client'; import bodyParser from '@koa/bodyparser'; import cors from '@koa/cors'; import Router from '@koa/router'; @@ -100,6 +101,7 @@ export default class Agent extends FrameworkMounter services: GatewayServices; allowedOAuthClients?: string[]; build?: (mcp?: RootHandler) => RootHandler; + mcpStopped?: RootHandler; } | null = null; private isRestarting = false; @@ -551,7 +553,11 @@ export default class Agent extends FrameworkMounter ); } - const { createGatewaySwitch, normalizeMountPath } = await import('@forestadmin/mcp-server'); + normalizeDomainList(this.gateway.allowedOAuthClients); + + const { createGatewaySwitch, makeIsMcpRoute, normalizeMountPath } = await import( + '@forestadmin/mcp-server' + ); const basePath = normalizeMountPath(this.gateway.basePath, 'basePath'); assertNoGatewayOverlap(basePath, this.completeMountPrefix, services); @@ -560,7 +566,10 @@ export default class Agent extends FrameworkMounter const bff = services.api ? this.gatewayApi(basePath, services.api) : null; const api = bff ? { matches: (path: string) => bff.claims(path), callback: bff.handle } : null; - const mcpStarting = services.mcp ? mcpUnavailable(basePath, 'starting') : null; + const isMcpRoute = makeIsMcpRoute(basePath); + const mcpStarting = services.mcp ? mcpUnavailable(isMcpRoute, 'starting') : null; + + if (services.mcp) this.gateway.mcpStopped = mcpUnavailable(isMcpRoute, 'stopped'); this.gateway.build = mcp => createGatewaySwitch({ @@ -600,10 +609,7 @@ export default class Agent extends FrameworkMounter return; } - const stopped = - this.isStopped && this.gateway.services.mcp - ? mcpUnavailable(this.gateway.basePath, 'stopped') - : undefined; + const stopped = this.isStopped ? this.gateway.mcpStopped : undefined; this.setGatewayCallback(this.gateway.build(stopped ?? mcp)); } diff --git a/packages/agent/src/gateway.ts b/packages/agent/src/gateway.ts index b2ac194f56..b8d3ede6da 100644 --- a/packages/agent/src/gateway.ts +++ b/packages/agent/src/gateway.ts @@ -19,16 +19,12 @@ const MCP_UNAVAILABLE = { stopped: 'The MCP server was stopped with the agent.', }; -export function mcpUnavailable(basePath: string, state: keyof typeof MCP_UNAVAILABLE): RootHandler { - const mcpPath = `${basePath}/mcp`; - const oauthPrefix = `${basePath}/oauth/`; - +export function mcpUnavailable( + isMcpRoute: (url: string) => boolean, + state: keyof typeof MCP_UNAVAILABLE, +): RootHandler { return { - matches: url => { - const [pathname] = url.split(/[?#]/, 1); - - return isWithin(pathname, mcpPath) || pathname.startsWith(oauthPrefix); - }, + matches: isMcpRoute, callback: (_req, res) => { res.statusCode = HTTP_SERVICE_UNAVAILABLE; res.setHeader('Content-Type', 'application/json'); diff --git a/packages/agent/test/agent-gateway-wiring.test.ts b/packages/agent/test/agent-gateway-wiring.test.ts index ed5bcb6bd1..7c342a275a 100644 --- a/packages/agent/test/agent-gateway-wiring.test.ts +++ b/packages/agent/test/agent-gateway-wiring.test.ts @@ -331,18 +331,9 @@ describe.each([ const { agent } = buildAgent(); agent.addGateway({ mcp: true }); const app = mount(agent); - const preparing = deferred(); - const prepare = (agent as any).prepareGateway.bind(agent); - jest.spyOn(agent as any, 'prepareGateway').mockImplementation(async () => { - await preparing.promise; - await prepare(); - }); - const prepareCalls = jest.mocked((agent as any).prepareGateway).mock.calls; const starting = agent.start(); - await until(() => prepareCalls.length > 0); await agent.stop(); - preparing.resolve(); await starting; const mcp = await request(app).post('/mcp'); @@ -405,6 +396,30 @@ describe.each([ expect((await request(app).get('/api/health')).status).toBe(503); }); + it('should answer 503 on the MCP discovery documents while the MCP starts', async () => { + const dataSource = deferred>(); + const getDataSource = jest.mocked(DataSourceCustomizer.prototype.getDataSource); + getDataSource.mockReturnValue(dataSource.promise); + const { agent } = buildAgent(); + agent.addGateway({ mcp: true, basePath: '/ai' }); + const app = mount(agent); + + const starting = agent.start(); + await until(() => getDataSource.mock.calls.length > 0); + const statuses = await Promise.all( + [ + '/.well-known/oauth-authorization-server/ai', + '/.well-known/oauth-protected-resource/ai/mcp', + '/ai/mcp', + ].map(async path => (await request(app).get(path)).status), + ); + await agent.stop(); + dataSource.resolve(factories.dataSource.build()); + await starting; + + expect(statuses).toEqual([503, 503, 503]); + }); + it('should leave /.well-known to the host in every window the MCP is not built', async () => { const dataSource = deferred>(); const getDataSource = jest.mocked(DataSourceCustomizer.prototype.getDataSource); @@ -435,11 +450,24 @@ describe.each([ expect((await request(app).get('/api/health')).status).toBe(503); expect((await request(app).post('/mcp')).status).toBe(503); - expect((await request(app).get('/.well-known/oauth-authorization-server')).status).toBe(404); + expect((await request(app).get('/.well-known/oauth-authorization-server')).status).toBe(503); }); }); describe('addGateway() checks at start()', () => { + it('should refuse a malformed allowedOAuthClients before serving /forest', async () => { + const { agent } = buildAgent(); + agent.addGateway({ api: {}, allowedOAuthClients: ['https://claude.ai'] }); + onExpress(agent); + + await expect(agent.start()).rejects.toThrow( + 'Invalid allowedOAuthClients entry "https://claude.ai"', + ); + + expect(mockMakeRoutes).not.toHaveBeenCalled(); + expect(mockBuildBff).not.toHaveBeenCalled(); + }); + it('should refuse another agent-bff version, naming both', async () => { mockBffVersion = '0.0.1'; const { agent } = buildAgent(); From a4aa5c7db7f0ac53353f65afe476e938520c15de Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 12 Oct 2026 15:16:03 +0200 Subject: [PATCH 09/11] test(agent-bff): name the gateway api 429 credit test after what it asserts --- packages/agent-bff/test/openapi/openapi-document.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index b2304b931b..c84f657dd1 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -608,7 +608,7 @@ describe('generateOpenApiDocument', () => { expect(header['Retry-After']?.description).not.toContain('could not be resolved'); }); - it('should credit the BFF itself with the 429, not only the agent', () => { + it('should credit the Gateway API itself with the 429, not only the agent', () => { expect(listResponses()['429'].description).toContain('Gateway API rate-limited'); }); From c0f36585094f7dbf37fdb4da448aedf91d561be2 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 12 Oct 2026 15:16:03 +0200 Subject: [PATCH 10/11] docs(adr): record the gateway shared oauth route --- ...-the-mcp-and-the-api-behind-a-shared-oauth-route.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/adr/2026-10-12-one-gateway-serves-the-mcp-and-the-api-behind-a-shared-oauth-route.md diff --git a/docs/adr/2026-10-12-one-gateway-serves-the-mcp-and-the-api-behind-a-shared-oauth-route.md b/docs/adr/2026-10-12-one-gateway-serves-the-mcp-and-the-api-behind-a-shared-oauth-route.md new file mode 100644 index 0000000000..f3cec03d3d --- /dev/null +++ b/docs/adr/2026-10-12-one-gateway-serves-the-mcp-and-the-api-behind-a-shared-oauth-route.md @@ -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: `/mcp`, `/oauth/*`, `/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 `/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 `/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`). From 28c1702d92c3a1f794c4cea9a3443b72404ef144 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 12 Oct 2026 15:22:43 +0200 Subject: [PATCH 11/11] fix(agent): claim the gateway oauth route for the api service too --- packages/agent/src/gateway.ts | 8 ++------ packages/agent/test/agent-gateway.test.ts | 16 +++++++++------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/agent/src/gateway.ts b/packages/agent/src/gateway.ts index b8d3ede6da..aaad3a3168 100644 --- a/packages/agent/src/gateway.ts +++ b/packages/agent/src/gateway.ts @@ -77,12 +77,8 @@ export function assertNoGatewayOverlap( } const claimed = [ - ...(mcp - ? [ - { path: `${basePath}/mcp`, service: 'MCP' }, - { path: `${basePath}/oauth`, service: 'MCP' }, - ] - : []), + ...(mcp ? [{ path: `${basePath}/mcp`, service: 'MCP' }] : []), + ...(mcp || api ? [{ path: `${basePath}/oauth`, service: mcp ? 'MCP' : 'API' }] : []), ...(api ? [{ path: `${basePath}/api/agent`, service: 'API' }] : []), ]; const overlap = claimed.find(({ path }) => isWithin(forestPath, path)); diff --git a/packages/agent/test/agent-gateway.test.ts b/packages/agent/test/agent-gateway.test.ts index 6145b8af58..d7cc13a5a8 100644 --- a/packages/agent/test/agent-gateway.test.ts +++ b/packages/agent/test/agent-gateway.test.ts @@ -410,13 +410,15 @@ describe('Agent.addGateway', () => { describe('route overlap with the agent', () => { it.each` - prefix | options | message - ${''} | ${{ basePath: '/forest', mcp: true }} | ${'basePath "/forest" overlaps the agent routes on /forest'} - ${'api'} | ${{ basePath: '/api/forest/x', api: {} }} | ${'basePath "/api/forest/x" overlaps the agent routes on /api/forest'} - ${'mcp'} | ${{ mcp: true }} | ${'The agent routes on /mcp/forest overlap /mcp, claimed by the Gateway MCP'} - ${'oauth'} | ${{ mcp: true }} | ${'The agent routes on /oauth/forest overlap /oauth, claimed by the Gateway MCP'} - ${'ai/mcp'} | ${{ basePath: '/ai', mcp: true }} | ${'The agent routes on /ai/mcp/forest overlap /ai/mcp, claimed by the Gateway MCP'} - ${'api/agent'} | ${{ api: {} }} | ${'The agent routes on /api/agent/forest overlap /api/agent, claimed by the Gateway API'} + prefix | options | message + ${''} | ${{ basePath: '/forest', mcp: true }} | ${'basePath "/forest" overlaps the agent routes on /forest'} + ${'api'} | ${{ basePath: '/api/forest/x', api: {} }} | ${'basePath "/api/forest/x" overlaps the agent routes on /api/forest'} + ${'mcp'} | ${{ mcp: true }} | ${'The agent routes on /mcp/forest overlap /mcp, claimed by the Gateway MCP'} + ${'oauth'} | ${{ mcp: true }} | ${'The agent routes on /oauth/forest overlap /oauth, claimed by the Gateway MCP'} + ${'ai/mcp'} | ${{ basePath: '/ai', mcp: true }} | ${'The agent routes on /ai/mcp/forest overlap /ai/mcp, claimed by the Gateway MCP'} + ${'api/agent'} | ${{ api: {} }} | ${'The agent routes on /api/agent/forest overlap /api/agent, claimed by the Gateway API'} + ${'oauth'} | ${{ api: {} }} | ${'The agent routes on /oauth/forest overlap /oauth, claimed by the Gateway API'} + ${'ai/oauth'} | ${{ basePath: '/ai', api: { tokenEncryptionKey: 'key' } }} | ${'The agent routes on /ai/oauth/forest overlap /ai/oauth, claimed by the Gateway API'} `('should refuse prefix "$prefix" with $options', async ({ prefix, options, message }) => { const { agent } = buildAgent(prefix); agent.addGateway(options);