-
Notifications
You must be signed in to change notification settings - Fork 12
feat(agent-bff): document permissions and openapi routes #1862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0ac7297
ec8405a
1a081ff
7b77508
6617280
d5924de
b53675b
fc97225
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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 = | ||
|
|
@@ -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 }, | ||
| }, | ||
|
|
@@ -66,6 +73,7 @@ type ResponseRef = { $ref: string }; | |
| interface ErrorResponseRefs { | ||
| byStatus: Record<string, ResponseRef>; | ||
| unsupportedActionResult: ResponseRef; | ||
| openapiDisabled: ResponseRef; | ||
| } | ||
|
|
||
| interface ErrorComponent { | ||
|
|
@@ -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]; | ||
|
|
@@ -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); | ||
|
|
||
|
|
@@ -127,7 +144,7 @@ function registerErrorResponses( | |
| }, | ||
| ); | ||
|
|
||
| return { byStatus, unsupportedActionResult }; | ||
| return { byStatus, openapiDisabled, unsupportedActionResult }; | ||
| } | ||
|
|
||
| function errorResponses( | ||
|
|
@@ -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 = | ||
| '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 ' + | ||
|
|
@@ -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 ' + | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The heartbeat listener is |
||
| '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 ' + | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With |
||
| '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 ' + | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| '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'], | ||
|
Tonours marked this conversation as resolved.
|
||
| }, | ||
| }); | ||
| } | ||
|
|
||
| function headDocumentResponses(): Record<string, { description: string }> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These reuse descriptions written for a JSON-bodied response — |
||
| 404: { | ||
| description: | ||
| 'The deployment runs with `BFF_OPENAPI_ENABLED=false`, so the document is not served ' + | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Word-for-word from |
||
| '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'] }, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dropping the |
||
| }; | ||
| } | ||
|
|
||
| function registerDocumentPath(registry: OpenAPIRegistry, errorRefs: ErrorResponseRefs): void { | ||
| const disabled = errorRefs.openapiDisabled; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Single use now that HEAD's 404 is inline — inline |
||
|
|
||
| registry.registerPath({ | ||
|
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>, | ||
|
|
@@ -425,6 +571,9 @@ export function generateOpenApiDocument( | |
| }, | ||
| }); | ||
|
|
||
| registerPermissionsPath(registry, errorRefs, timezoneHeader); | ||
| registerDocumentPath(registry, errorRefs); | ||
|
|
||
| if (aiErrorRefs) registerAiQueryPath(registry, aiErrorRefs); | ||
|
|
||
| if (unfolding) { | ||
|
|
@@ -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: '/' }], | ||
| }); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.