Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/agent-bff/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @forestadmin/agent-bff

Standalone REST BFF (Backend-For-Frontend) that lets a trusted third-party UI call a Forest Admin
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.

It is a bootable Koa 3 server with a `/health` endpoint, a version header, env-driven config
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
"dist/**/*.d.ts"
],
"scripts": {
"build": "tsc",
"build": "tsc && yarn build:copy",
"build:watch": "tsc --watch",
"build:copy": "node -e \"require('fs').copyFileSync(require.resolve('redoc/bundles/redoc.standalone.js'), 'dist/docs/redoc.standalone.js')\"",
"start": "node dist/cli.js",
"start:dev": "node --env-file=.env dist/cli.js",
"clean": "rm -rf coverage dist",
Expand All @@ -47,6 +48,7 @@
"@types/koa": "^2.13.5",
"@types/supertest": "^6.0.2",
"openapi3-ts": "4.6.1",
"redoc": "2.5.3",
"supertest": "^7.1.3"
}
}
11 changes: 10 additions & 1 deletion packages/agent-bff/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { parseConfig } from './config/env-config';
import createCorsMiddleware from './cors/cors-middleware';
import createPerKeyOriginMiddleware from './cors/per-key-origin';
import createDataRoutesMiddleware from './data/data-routes-middleware';
import createDocsRoutes from './docs/docs-routes';
import { extractErrorMessage } from './errors';
import { unauthorized } from './http/bff-http-error';
import BFFHttpServer from './http/bff-http-server';
Expand All @@ -28,7 +29,7 @@ import ForestServerClient from './oauth/forest-server-client';
import createOAuthRoutes from './oauth/oauth-routes';
import createInMemorySessionStore from './oauth/session-store';
import createTokenCipher from './oauth/token-cipher';
import createOpenApiRoutes from './openapi/openapi-routes';
import createOpenApiRoutes, { OPENAPI_PATH } from './openapi/openapi-routes';
import PermissionsCache from './permissions/permissions-cache';
import PermissionsClient from './permissions/permissions-client';
import createPermissionsRoutesMiddleware from './permissions/permissions-routes-middleware';
Expand Down Expand Up @@ -313,6 +314,14 @@ export default async function runCli(
...agentErrorMiddleware,
bodyParser({ jsonLimit: BODY_LIMIT }),
...oauthMiddlewares,
// Outside the agent-scoped chain on purpose: the viewer is a public page, the document it fetches
// is not. Gated on the edge being mounted too, like the error middleware above: with no agent
// chain there is no document to fetch, and the page would only ever reach a bare Koa 404.
createDocsRoutes({
enabled: config.openapiEnabled && agentMiddlewares.length > 0,
documentPath: OPENAPI_PATH,
logger,
}),
...agentMiddlewares,
];
const server = new BFFHttpServer({
Expand Down
166 changes: 166 additions & 0 deletions packages/agent-bff/src/docs/docs-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* The page is served WITHOUT credentials, so it must carry no schema: it is an empty shell that asks
* the caller for a BFF API key, fetches the document with it, and hands the parsed object to Redoc.
* That is the only design that is both openable in a browser — which sends no header when it
* navigates — and compatible with a document that is never reachable unauthenticated.
*
* The key is never persisted: it is read from the input, passed down as an argument, and the input is
* cleared. Once the document is fetched the page has no further use for it.
*
* Deliberately NOT a `<form>`. A form with no `action` navigates to `/docs?key=<the key>` the moment
* its default submit is not prevented — a CSP that blocks this inline script is enough — which would
* put the key in the browser history and in every access log on the way. A form submit is also what
* Chrome reads as a login, and it then offers to save the key whatever `autocomplete` says. With no
* form there is no default action to prevent and no submit to observe: without this script the button
* does nothing at all.
*/
import SAMPLES_SCRIPT from './docs-samples';
import { FAVICON_SVG, PAGE_STYLES, REDOC_THEME } from './docs-theme';

/**
* `untrustedSpec` because the descriptions in the document come from the agent's own schema, which is
* customer-authored, and Redoc renders their markdown as HTML unsanitized otherwise.
*/
const REDOC_OPTIONS = { hideDownloadButton: true, untrustedSpec: true, theme: REDOC_THEME };

export default function renderDocsPage(documentPath: string, bundlePath: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Forest BFF API</title>
<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}" />
<style>${PAGE_STYLES} </style>
</head>
<body>
<div id="unlock">
<strong>Forest<span>.</span></strong>
<label for="key">BFF API key</label>
<input id="key" type="password" autocomplete="off" spellcheck="false" />
<button id="load" type="button">Load the API document</button>
</div>
<div id="error"></div>
<div id="redoc"></div>
<script src="${bundlePath}"></script>
<script>
(function () {
var DOCUMENT_PATH = ${JSON.stringify(documentPath)};
var BUNDLE_PATH = ${JSON.stringify(bundlePath)};
var REDOC_OPTIONS = ${JSON.stringify(REDOC_OPTIONS)};
var unlock = document.getElementById('unlock');
var input = document.getElementById('key');
var button = document.getElementById('load');
var errorBox = document.getElementById('error');
var attempts = 0;
${SAMPLES_SCRIPT}
function show(message) {
errorBox.textContent = message;
errorBox.setAttribute('data-shown', '');
}

function hide() {
errorBox.removeAttribute('data-shown');
}

function describe(status, body) {
var error = body && body.error;

if (error && error.type) {
return 'The BFF answered ' + status + ' ' + error.type + ': ' + (error.message || '');
}

return 'The BFF answered ' + status + ': ' + JSON.stringify(body);
}

/**
* Kept out of the fetch chain: a throw from here is a viewer problem, and reporting it as
* "could not reach the document" would point the reader at the wrong thing.
*/
function render(spec) {
if (typeof Redoc === 'undefined') {
show('The Redoc viewer did not load from ' + BUNDLE_PATH + ', so the document cannot be rendered.');

return;
}

unlock.style.display = 'none';

try {
Redoc.init(withSamples(spec), REDOC_OPTIONS, document.getElementById('redoc'));
} catch (initError) {
unlock.style.display = '';
show('The Redoc viewer could not render the document: ' + initError);
}
}

/**
* Every completion is checked against \`attempt\`: two submissions in quick succession — a
* mistyped key corrected straight away — resolve in whatever order the network gives, and a
* late answer from the abandoned one would otherwise render its document or report its error
* over the current attempt's result.
*/
function load(key) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
hide();

var attempt = ++attempts;
var current = function () {
return attempt === attempts;
};

fetch(DOCUMENT_PATH, {
cache: 'no-store',
headers: { 'X-Forest-Bff-Key': key },
})
.then(function (response) {
return response.text().then(function (text) {
try {
return { ok: response.ok, status: response.status, body: JSON.parse(text) };
} catch (parseError) {
// Never successful, whatever the status said: a body we cannot parse is not a
// document, and handing this placeholder to Redoc would hide why.
return {
ok: false,
status: response.status,
body: { error: { type: 'unreadable_response', message: text.slice(0, 200) } },
};
}
});
})
.then(function (result) {
if (!current()) return;

if (!result.ok) {
show(describe(result.status, result.body));

return;
}

render(result.body);
})
.catch(function (fetchError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch is chained after the second then, so a throw from Redoc.init at line 91, including the ReferenceError when the bundle script did not load, is reported as "Could not reach /agent/openapi.json" and points the reader at the wrong thing: let's check typeof Redoc === 'undefined' before init with its own message, or catch around the init separately.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 749ce16. The init moved out of the fetch chain into a render() with a typeof Redoc === 'undefined' guard (its own message, naming the bundle path) and its own try/catch around Redoc.init. The chain's .catch now only ever reports a fetch failure, which is what it says.

if (!current()) return;

show('Could not reach ' + DOCUMENT_PATH + ': ' + fetchError);
});
}

function unlockDocument() {
var key = input.value.trim();
input.value = '';

if (key) load(key);
else show('A BFF API key is required: the document is never served unauthenticated.');
}

button.addEventListener('click', unlockDocument);
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') unlockDocument();
});
})();
</script>
</body>
</html>
`;
}
106 changes: 106 additions & 0 deletions packages/agent-bff/src/docs/docs-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Logger } from '../ports/logger-port';
import type { Middleware } from 'koa';

import { existsSync, readFileSync } from 'fs';
import path from 'path';

import renderDocsPage from './docs-page';

export const DOCS_PATH = '/docs';
export const DOCS_BUNDLE_PATH = '/docs/redoc.standalone.js';

const BUNDLE_FILE = 'redoc.standalone.js';
const READ_METHODS = new Set(['GET', 'HEAD']);

export interface DocsRoutesOptions {
enabled: boolean;
/** Where the shell fetches the document. Passed in so this module never reaches into `src/openapi`. */
documentPath: string;
logger: Logger;
/** The bundle lookup, as a seam: an install that shipped without the asset is a real state to serve. */
resolveBundlePath?: () => string | undefined;
}

/**
* The bundle is copied next to this module at build time (`build:copy`), which is what a published
* install serves. Running from `src` — tests, `build:watch` — there is nothing to copy to, so the
* `redoc` devDependency is resolved instead: the same file, from the package that pins its version.
*/
function resolveBundle(): string | undefined {
const copied = path.join(__dirname, BUNDLE_FILE);

if (existsSync(copied)) return copied;

try {
return require.resolve(`redoc/bundles/${BUNDLE_FILE}`);
} catch {
/* istanbul ignore next — `redoc` is a devDependency of this package, so the lookup only fails in
a published install whose `build:copy` did not run. */
return undefined;
}
}

/**
* Serves the Redoc viewer OUTSIDE `/agent`, deliberately: the agent prefix answers 401 to a request
* with no credential (`auth-mode.ts`), and a browser navigating to a page sends none. Both routes are
* public, and both are inert — the shell carries no schema and the bundle is a third-party asset.
* The document itself stays gated.
*
* Disabled, or unable to find its bundle, the middleware falls through rather than throwing: `/docs`
* is not covered by the agent-scoped error middleware, so a thrown error would surface as a bare 500
* instead of the BFF error contract. A 404 also keeps a disabled deployment from advertising a page
* it does not serve.
*/
export default function createDocsRoutes({
enabled,
documentPath,
logger,
resolveBundlePath = resolveBundle,
}: DocsRoutesOptions): Middleware {
const bundle = enabled ? resolveBundlePath() : undefined;

if (enabled && !bundle) {
logger('Warn', `API documentation page disabled: ${BUNDLE_FILE} is missing from this install`);
}

const page = bundle ? renderDocsPage(documentPath, DOCS_BUNDLE_PATH) : undefined;
let script: string | undefined;

return async function docsRoutes(ctx, next) {
const isDocsPath = ctx.path === DOCS_PATH || ctx.path === DOCS_BUNDLE_PATH;

if (!bundle || !isDocsPath || !READ_METHODS.has(ctx.method)) {
await next();

return;
}

if (ctx.path === DOCS_BUNDLE_PATH) {
// Read once and kept in memory: ~1 MB, served on every page load. A file that resolved at boot
// and is unreadable now falls through like a missing one: no error middleware covers this path.
if (script === undefined) {
try {
script = readFileSync(bundle, 'utf8');
} catch (error) {
logger('Warn', `API documentation bundle unreadable: ${bundle}`, { error });

await next();

return;
}
}

ctx.status = 200;
ctx.type = 'application/javascript';
ctx.set('Cache-Control', 'public, max-age=3600');
ctx.body = script;

return;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}

ctx.status = 200;
ctx.type = 'text/html';
ctx.set('Cache-Control', 'no-store');
ctx.body = page;
};
}
Loading
Loading