Skip to content
4 changes: 3 additions & 1 deletion packages/agent-bff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ Everything the agent proxy exposes lives under `/agent/v1`. The data and action
| `/agent/v1/permissions` | `GET` | what the caller may see and do, **as display hints** for graying out UI — never an authorization decision |
| `/agent/openapi.json` | `GET`/`HEAD` | the unfolded document, auth-gated, when `BFF_OPENAPI_ENABLED` is on |

`/health` and `/oauth/*` sit outside the prefix and outside the auth edge.
`/health`, the `/oauth/*` login routes and the `/docs` viewer (page and `redoc.standalone.js`
bundle) sit outside the prefix and outside the auth edge; `/health` and `/docs` answer `HEAD` as
well as `GET`.

**The agent enforces permissions and scopes, not the BFF.** Each proxied call carries a
short-lived agent token the BFF mints for the caller, so a request reaches the agent as that
Expand Down
159 changes: 154 additions & 5 deletions packages/agent-bff/src/openapi/openapi-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@ import {
ListRequestSchema,
ListResponseSchema,
MessagelessErrorResponseSchema,
PermissionHintsSchema,
RelationCountRequestSchema,
RelationListRequestSchema,
} from './schemas';
import registerUnfoldedPaths from './unfolded-paths';
import { z } from './zod-openapi';
import BODY_LIMIT, { AI_BODY_LIMIT } from '../http/body-limit';
import { PERMISSIONS_CACHE_TTL_MS } from '../permissions/permissions-cache';

export const OPENAPI_VERSION = '3.1.0';
export const ROUTE_PREFIX = '/agent/v1';
export const DOCUMENT_PATH = '/agent/openapi.json';

const SESSION_SCHEME = 'bffSession';
const API_KEY_SCHEME = 'bffApiKey';
Expand All @@ -41,7 +44,7 @@ const ERROR_STATUSES: Record<string, string> = {
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',
502: 'The agent could not be reached',
503: 'The agent schema is unavailable, the agent returned a 5xx, or the API key could not be resolved',
503: 'The agent schema is unavailable, the agent returned a 5xx, the API key could not be resolved, or the Forest permissions could not be fetched and no fresh cache was left (type permissions_unavailable)',
};

const UNSUPPORTED_RESULT_DESCRIPTION =
Expand All @@ -53,9 +56,13 @@ const MESSAGELESS_ERROR_RESPONSE_REF = '#/components/schemas/MessagelessErrorRes

const UNSUPPORTED_ACTION_RESULT_COMPONENT = 'UnsupportedActionResult';

const OPENAPI_DISABLED_COMPONENT = 'Error404OpenapiDisabled';

const RETRY_AFTER_HEADER = {
'Retry-After': {
description: 'Seconds to wait before retrying. Set when the API key could not be resolved.',
description:
'Seconds to wait before retrying. Set when the BFF could not reach the Forest server — an ' +
'unresolvable API key, or permissions it could not fetch.',
required: false,
schema: { type: 'integer' as const },
},
Expand All @@ -66,6 +73,7 @@ type ResponseRef = { $ref: string };
interface ErrorResponseRefs {
byStatus: Record<string, ResponseRef>;
unsupportedActionResult: ResponseRef;
openapiDisabled: ResponseRef;
}

interface ErrorComponent {
Expand Down Expand Up @@ -97,6 +105,13 @@ function registerErrorResponses(
registry.register('ErrorResponse', ErrorResponseSchema);

const byStatus: Record<string, ResponseRef> = {};
const openapiDisabled = registerErrorComponent(registry, OPENAPI_DISABLED_COMPONENT, {
description:
'The deployment runs with `BFF_OPENAPI_ENABLED=false`, so the document is not served over ' +
'HTTP. The body is typed `openapi_disabled`; a bare 404 with no typed body means the agent ' +
'edge is not mounted at all.',
schema: { $ref: ERROR_RESPONSE_REF },
});

statuses.forEach(status => {
const description = ERROR_STATUSES[status];
Expand All @@ -112,7 +127,9 @@ function registerErrorResponses(

// A document with no action path must not carry this response, nor the messageless body it
// references: an unreferenced component trips redocly's unused-component rule.
if (!withActionResults) return { byStatus, unsupportedActionResult: byStatus['501'] };
if (!withActionResults) {
return { byStatus, openapiDisabled, unsupportedActionResult: byStatus['501'] };
}

registry.register('MessagelessErrorResponse', MessagelessErrorResponseSchema);

Expand All @@ -127,7 +144,7 @@ function registerErrorResponses(
},
);

return { byStatus, unsupportedActionResult };
return { byStatus, openapiDisabled, unsupportedActionResult };
}

function errorResponses(
Expand Down Expand Up @@ -321,6 +338,14 @@ const SHARED_DESCRIPTION =
'like JSON, while any other content type is read as absent, which silently drops any filter, ' +
'sort, or page.';

const SURFACE_DESCRIPTION =
Comment thread
Tonours marked this conversation as resolved.
'Only the auth-gated `/agent` surface is described here. Three live route families are ' +
'deliberately absent because they sit outside that surface and outside its auth edge: the ' +
'`/health` probe (`GET` and `HEAD`), which answers 200 `ok` or 503 `degraded` to an ' +
'unauthenticated request; the `/oauth/*` login routes; and the unauthenticated `/docs` viewer ' +
'that renders this very document — `GET` and `HEAD` on the page and on its public ' +
'`redoc.standalone.js` bundle. The package README documents all three.';

const GENERIC_DESCRIPTION =
'Paths are generic: one per operation, with the collection, relation and action passed as path ' +
'segments, and no field enumerated. This is the fallback form — a deployment configured to ' +
Expand All @@ -338,6 +363,127 @@ const UNFOLDED_DESCRIPTION =
'no list or count route. This document describes the whole exposed schema regardless of the ' +
'caller: it is not filtered by the permissions of whoever fetched it.';

function registerPermissionsPath(
registry: OpenAPIRegistry,
errorRefs: ErrorResponseRefs,
timezoneHeader: ReturnType<OpenAPIRegistry['registerParameter']>[],
): void {
registry.registerPath({
method: 'get',
path: `${ROUTE_PREFIX}/permissions`,
operationId: 'getPermissionHints',
summary: 'Read what the caller may see and do, as display hints',
description:
'Answers for the caller behind the credentials, so two callers get two different payloads. ' +
'The hints come from the Forest permissions, cached for ' +
`${
PERMISSIONS_CACHE_TTL_MS / 60_000
} minutes, so they lag a change made in Forest. The agent runs with ` +
'`instantCacheRefresh` by default, so its own cache has no meaningful expiry — about a year — ' +
'and freshness rides on the Forest event stream: as long as those events reach it, the agent ' +
'enforces the new permission while these hints are still stale. If the stream is cut — a ' +
'reverse proxy that swallows it, which the agent logs — the agent can hold the old permission ' +

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 heartbeat listener is { once: true } and detectBuffering runs once at subscribe, so this only fires when the first heartbeat never arrives — "logs at startup", or drop the parenthetical rather than implying continuous detection?

'far longer than these hints, and the hints are then the fresher of the two. An agent ' +
'explicitly configured with `instantCacheRefresh: false` caches for ' +
'`permissionsCacheDurationInSeconds` — 15 minutes by default, configurable with a 60-second ' +
'floor — independently of these hints, and the two can then disagree in either direction ' +

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.

With instantCacheRefresh: false the agent refetches on a denial (action-permission.ts:48-56), so a newly granted permission lands on the next call and only a revocation lingers — the disagreement is one-directional there.

'until both expire. A 503 here is `permissions_unavailable` or ' +
'`key_resolution_unavailable`, which always carry Retry-After, or `schema_unavailable`, ' +
'which does not. Unlike the context, document and AI-query routes, this one sits behind the ' +

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.

hasAiQueryRoute defaults to false and registerAiQueryPath is conditional (:577), so on those deployments this points at a path the document doesn't carry — "and the AI-query relay where published"?

'timezone middleware even though it reads no timezone, so a deployment with no configured ' +
'default answers 400 `missing_timezone` unless `X-Forest-Timezone` is sent.',
security: SECURITY,
request: {
query: z.object({
collections: z
.union([z.string(), z.array(z.string())])
.optional()
.openapi({
description:
'Comma-separated collection names to restrict the answer to, repeatable — a ' +
'repeated parameter is joined with commas before being split again, so both forms ' +
'behave the same. Absent means every exposed collection. Entries are trimmed and ' +
'deduplicated, and a name the schema does not expose is dropped silently rather ' +
'than rejected — so an empty `collections` object means none of the names matched.',
}),
}),
headers: timezoneHeader,
},
responses: {
200: {
description: 'The display hints for the caller role',
content: { 'application/json': { schema: PermissionHintsSchema } },
},
400: errorRefs.byStatus['400'],
401: errorRefs.byStatus['401'],
403: errorRefs.byStatus['403'],
500: errorRefs.byStatus['500'],
501: errorRefs.byStatus['501'],
503: errorRefs.byStatus['503'],
Comment thread
Tonours marked this conversation as resolved.
},
});
}

function headDocumentResponses(): Record<string, { description: string }> {

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.

A parameterless function returning a static literal, called once — a module const beside ERROR_STATUSES and RETRY_AFTER_HEADER matches the file's convention and removes the ordering constraint the commit title hints at.

return {
200: { description: 'The document exists and is readable; no body is returned for HEAD' },
400: { description: ERROR_STATUSES['400'] },
401: { description: ERROR_STATUSES['401'] },
403: { description: ERROR_STATUSES['403'] },
Comment on lines +430 to +432

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.

These reuse descriptions written for a JSON-bodied response — ['403'] says "the body carries the approving roles" — while the 404 was reworded for exactly that reason; one "read the status, not the type" caveat on the HEAD description would cover all five.

404: {
description:
'The deployment runs with `BFF_OPENAPI_ENABLED=false`, so the document is not served ' +

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.

Word-for-word from OPENAPI_DISABLED_COMPONENT at :110, and the comment at :97-99 argues against inlining — a shared const both concatenate, plus a line saying why HEAD is the exception?

'over HTTP. The status is the same as the GET one, but the typed `openapi_disabled` body ' +
'never comes back on a HEAD: read the status, not the type.',
},
500: { description: ERROR_STATUSES['500'] },
503: { description: ERROR_STATUSES['503'] },

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.

Dropping the $refs also dropped the Retry-After the Error503 component carried (:124), but line 479 still promises header parity and the runtime does send it (error-middleware.ts:30 is method-agnostic) — headers: RETRY_AFTER_HEADER here, with content still absent?

};
}

function registerDocumentPath(registry: OpenAPIRegistry, errorRefs: ErrorResponseRefs): void {
const disabled = errorRefs.openapiDisabled;

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.

Single use now that HEAD's 404 is inline — inline errorRefs.openapiDisabled at the GET and drop the local?


registry.registerPath({
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
method: 'get',
path: DOCUMENT_PATH,
operationId: 'getOpenApiDocument',
summary: 'Read this document',
description:
'Serves this very document, behind the same credentials as every other `/agent` route: ' +
'without them the schema it describes is not readable. `HEAD` is served identically. The ' +
'answer is never cached (`Cache-Control: no-store`) and is regenerated when the BFF ' +
'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',
content: { 'application/json': { schema: z.unknown() } },
},
400: errorRefs.byStatus['400'],
401: errorRefs.byStatus['401'],
403: errorRefs.byStatus['403'],
404: disabled,
500: errorRefs.byStatus['500'],
503: errorRefs.byStatus['503'],
},
});

registry.registerPath({
method: 'head',
path: DOCUMENT_PATH,
operationId: 'headOpenApiDocument',
summary: 'Probe this document without fetching it',
description:
'Same route as `GET`, answering the same statuses and headers with no body, so a client can ' +
'probe whether the document is enabled and reachable before re-fetching it.',
security: SECURITY,
request: {},
responses: headDocumentResponses(),
});
}

function registerAiQueryPath(
registry: OpenAPIRegistry,
aiErrorRefs: Record<string, ResponseRef>,
Expand Down Expand Up @@ -425,6 +571,9 @@ export function generateOpenApiDocument(
},
});

registerPermissionsPath(registry, errorRefs, timezoneHeader);
registerDocumentPath(registry, errorRefs);

if (aiErrorRefs) registerAiQueryPath(registry, aiErrorRefs);

if (unfolding) {
Expand Down Expand Up @@ -483,7 +632,7 @@ export function generateOpenApiDocument(
license: { name: 'GPL-3.0', url: 'https://www.gnu.org/licenses/gpl-3.0.html' },
description: `${
unfolding ? UNFOLDED_DESCRIPTION : GENERIC_DESCRIPTION
} ${SHARED_DESCRIPTION}`,
} ${SHARED_DESCRIPTION} ${SURFACE_DESCRIPTION}`,
},
servers: [{ url: '/' }],
});
Expand Down
59 changes: 59 additions & 0 deletions packages/agent-bff/src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
SortClauseInput,
TimezoneInput,
} from '../data/request-schemas';
import { DISPLAY_HINT_FINALITY } from '../permissions/build-permission-hints';
import { RELATIONSHIP_TYPES } from '../read-model/read-model';
import { MAX_FILTER_DEPTH } from '../validation/capabilities-validator';

Expand Down Expand Up @@ -280,6 +281,64 @@ export const ContextResponseSchema = z
'deployment runs without the OAuth configuration.',
});

const CrudHintsSchema = z
.object({
browse: z.boolean(),
read: z.boolean(),
edit: z.boolean(),
add: z.boolean(),
delete: z.boolean(),
export: z.boolean(),
})
.openapi('CrudHints', {
description:
'The six collection-level rights of the caller role, as Forest states them. `browse` false ' +
Comment thread
Tonours marked this conversation as resolved.
'does not remove the collection from this response, nor from the schema contract. On a ' +
'development environment all six are true whatever the role, so an all-true block is not a ' +
'statement about the role.',
});

const ActionHintsSchema = z
.object({
collection: z.string(),
name: z.string(),
visible: z.boolean(),
requiresApprovalHint: z.boolean(),
canApproveHint: z.boolean(),
canSelfApproveHint: z.boolean(),
finality: z.literal(DISPLAY_HINT_FINALITY),
})
.openapi('ActionHints', {
description:
'Display hints for one action. `visible` is the trigger right, so it is what gates showing ' +
'the button; `requiresApprovalHint` says a trigger by this role opens an approval request ' +
'instead of running, and the two approval flags say whether this role may approve such a ' +
'request, and its own. `collection` and `name` repeat the keys of the enclosing objects, so ' +
'a flattened list stays self-describing. On a development environment every exposed action ' +
'is visible with all three approval flags false, whatever the role. `finality` is always ' +
`\`${DISPLAY_HINT_FINALITY}\`: it marks the whole payload as UI advice.`,
});

export const PermissionHintsSchema = z
.object({
collections: z.record(
z.string(),
z.object({ crud: CrudHintsSchema, actions: z.record(z.string(), ActionHintsSchema) }),
),
visibleActions: z.array(z.object({ collection: z.string(), name: z.string() })),
})
.openapi('PermissionHints', {
description:
'What the caller may see and do, **as display hints only** — gray out a button, hide a ' +
'column. Never an authorization decision: the agent re-checks the caller on every proxied ' +
'call, so a hint reading true grants nothing and a client must still handle 403. ' +
'`collections` is keyed by the exact schema collection name, and `actions` by the exact ' +
'action name — only the actions the schema exposes with an endpoint, so an action absent ' +
'here is not a permission answer. `visibleActions` flattens the actions whose `visible` is ' +
'true, so a client can build its menu without walking `collections`. The hints describe the ' +
'caller role alone, and say nothing about record-level scopes.',
});

export const AiQueryRequestSchema = z.unknown().openapi('AiQueryRequest', {
description:
'Passed through to the Forest AI proxy without validation or rewriting, so the authority on ' +
Expand Down
10 changes: 6 additions & 4 deletions packages/agent-bff/test/openapi/openapi-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('renderOpenApi', () => {
it('should emit the generic document when nothing is configured to unfold against', async () => {
const document = JSON.parse(await renderOpenApi({}, noopLogger));

expect(Object.keys(document.paths)).toHaveLength(7);
expect(Object.keys(document.paths)).toHaveLength(9);
expect(document.info.description).toContain('Paths are generic');
});

Expand Down Expand Up @@ -141,9 +141,11 @@ describe('renderOpenApi', () => {
const document = JSON.parse(await renderOpenApi(VALID_ENV, noopLogger));

expect(Object.keys(document.paths).sort()).toEqual([
'/agent/openapi.json',
'/agent/v1/context',
'/agent/v1/orders/count',
'/agent/v1/orders/list',
'/agent/v1/permissions',
'/agent/v1/users/actions/Mark%20as%20paid/execute',
'/agent/v1/users/actions/Mark%20as%20paid/form',
'/agent/v1/users/count',
Expand Down Expand Up @@ -186,14 +188,14 @@ describe('renderOpenApi', () => {
await renderOpenApi({ ...VALID_ENV, AGENT_URL: undefined }, noopLogger),
);

expect(Object.keys(document.paths)).toHaveLength(7);
expect(Object.keys(document.paths)).toHaveLength(9);
expect(fetchSchema).not.toHaveBeenCalled();
});

it('should ignore a broken server-only setting, which the export does not use', async () => {
const document = JSON.parse(await renderOpenApi({ HTTP_PORT: 'nope' }, noopLogger));

expect(Object.keys(document.paths)).toHaveLength(7);
expect(Object.keys(document.paths)).toHaveLength(9);
});

it('should still reject a broken setting once the deployment asks to be unfolded', async () => {
Expand Down Expand Up @@ -271,7 +273,7 @@ describe('dispatchCli', () => {

const document = JSON.parse(stdout.mock.calls[0][0] as string);

expect(Object.keys(document.paths)).toHaveLength(7);
expect(Object.keys(document.paths)).toHaveLength(9);
} finally {
stdout.mockRestore();
}
Expand Down
Loading
Loading