Skip to content

feat(agent-bff): serve the OpenAPI document in a browser through Redoc - #1829

Open
nbouliol wants to merge 8 commits into
feature/prd-687-verify-a-generated-client-calls-list-count-form-and-executefrom
feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc
Open

feat(agent-bff): serve the OpenAPI document in a browser through Redoc#1829
nbouliol wants to merge 8 commits into
feature/prd-687-verify-a-generated-client-calls-list-count-form-and-executefrom
feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc

Conversation

@nbouliol

@nbouliol nbouliol commented Aug 14, 2026

Copy link
Copy Markdown
Member

Stacked on #1828 — base it there, review this diff on its own, and retarget to main once #1828 lands.

What

GET /docs renders the BFF's dynamic OpenAPI document in a browser, with Redoc served by the BFF itself. GET /docs/redoc.standalone.js serves the viewer bundle.

Why it is shaped this way

BFF auth is header-only — Authorization: Bearer <bff_access> or X-Forest-Bff-Key (src/auth/auth-mode-middleware.ts), no cookie anywhere in the package. resolveAuthMode throws unauthorized() when neither header is present (src/auth/auth-mode.ts:24-30), and that middleware covers every /agent/* path. A browser navigating to a page sends neither header, and Redoc cannot attach one to its own spec fetch. So:

  • both routes sit outside /agent, next to the existing public /oauth/* routes;
  • the page is an empty shell — no schema in it, nothing to leak;
  • it prompts for a BFF API key, fetches /agent/openapi.json with that header itself, and passes the parsed object to Redoc.init.

The document therefore stays exactly as unreachable as before: test/cli-core.test.ts asserts /agent/openapi.json still answers 401 while /docs answers 200, which is the interaction the mount invariant of #1827 cannot see on its own.

The key is held in memory only — passed as an argument, input cleared, never written to localStorage or sessionStorage, never put in a URL.

Bundle provenance

redoc is a devDependency; its redoc.standalone.js is copied into dist at build time (build:copy, the pattern forest-cloud already uses for its templates). Consequences:

  • no BFF consumer installs redoc's dependency tree;
  • files: dist/**/*.js already covers the copied asset — no packaging change needed;
  • running from src (tests, build:watch) there is nothing to copy to, so the route falls back to resolving the devDependency. Both paths are covered by the tests.

Not a CDN, deliberately: the page holds a credential in memory, and a third-party script in that page could read it. Pinning an SRI hash would mitigate that at the cost of a manual hash bump per version.

Disabled state

Same flag as the document, BFF_OPENAPI_ENABLED. When it is off both routes fall through to 404 rather than throw: /docs is outside the agent-scoped error middleware (src/cli-core.ts wraps createErrorMiddleware in agentScoped), so a thrown openapiDisabled() would surface as a bare Koa 500 instead of the BFF error contract. A 404 also keeps a disabled deployment from advertising a page it does not serve.

Structural guarantee

src/docs/ imports nothing from src/openapi/ — the document path is passed in from cli-core. The existing openapi-mount-invariant.test.ts enforces this mechanically: any such import would show up in openapiImportsOutsideTheOpenapiDir() and fail. That is a stronger statement than the substring assertion on the page body, and both are in place.

Tests

10 route tests plus 3 full-chain tests: page and bundle public, page carries no schema, page never cached, bundle served as a script, document still 401, both routes 404 when disabled, non-docs paths and writes passed through. Package suite: 71 suites / 1106 tests green, yarn build copies the bundle into dist/docs/.

Not in scope

No "try it out" console, no write path from the page, and no OAuth login flow in the page — the BFF's OAuth is a registered-client authorization server, so the page would have to be registered as a client first.

Fixes PRD-965

🤖 Generated with Claude Code

Note

Add public Redoc OpenAPI viewer at /docs in agent-bff

  • Serves a credential-less HTML shell at /docs that prompts for a BFF API key, then client-side fetches the protected OpenAPI document via X-Forest-Bff-Key header and renders it with Redoc.
  • The docs routes are mounted outside the agent-scoped middleware chain so the viewer page is public while the OpenAPI document stays gated. Routes are only enabled when config.openapiEnabled is true and agent middlewares exist.
  • The Redoc standalone bundle is served from /docs/redoc.standalone.js, copied at build time into dist/docs/ and cached in-memory on first request.
  • New modules added: docs-page.ts, docs-routes.ts, and docs-theme.ts. Also renames "Forest Admin" to "Forest" across README and OpenAPI info.title.
  • Risk: createDocsRoutes in docs-routes.ts falls through (404) if the Redoc bundle cannot be resolved at boot or read at request time; reviewers should confirm build:copy in package.json runs before serve so the bundle exists under dist/docs/.

Changes since #1829 opened

  • Implemented race condition handling in the renderDocsPage function's inline script to track concurrent fetch attempts and ignore stale responses [389d25e]
  • Added comprehensive test coverage for the renderDocsPage function's inline script behavior using a VM sandbox environment [389d25e]
  • Modified docs-page.renderDocsPage response handling to treat unparsable JSON bodies as errors [25c20fe]
  • Extended test harness and added test coverage for unparsable response bodies [25c20fe]

Macroscope summarized 475bec7.

@linear-code

linear-code Bot commented Aug 14, 2026

Copy link
Copy Markdown

PRD-965

@qltysh

qltysh Bot commented Aug 14, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (4)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/docs/docs-theme.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/docs/docs-page.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/docs/docs-routes.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Comment thread packages/agent-bff/src/docs/docs-routes.ts
<body>
<form id="unlock">
<label for="key">BFF API key</label>
<input id="key" name="key" type="password" autocomplete="off" spellcheck="false" />

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 form has no method and no action, so with inline scripts blocked the preventDefault at line 99 never runs and the browser navigates to /docs?key=<secret>, putting the key in browser history, BFF access logs and any proxy in between: let's drop name="key" since the script reads the input by id, and add method="post" as a belt.

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 — differently: the <form> is gone entirely rather than neutralised.

method="post" still leaves a form that submits. The POST would land on 404 (READ_METHODS only holds GET/HEAD), so no key in a URL, but the form element stays and that is what Chrome reads as a login in your next comment. With a plain <div id="unlock">, a <button type="button"> and an explicit keydown/Enter listener there is no default action to prevent: without the inline script the button does nothing at all instead of navigating. name="key" is gone with it.

<body>
<form id="unlock">
<label for="key">BFF API key</label>
<input id="key" name="key" type="password" autocomplete="off" spellcheck="false" />

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 password input, a form hidden on success and a cleared value are exactly Chrome's successful-login heuristic, which ignores autocomplete="off" on password fields and offers to save the key, so the "key is never persisted" claim in the header comment breaks as soon as the user accepts: let's use autocomplete="new-password", or state that limit in the header comment instead of claiming the key is never persisted.

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, via the form removal rather than autocomplete.

new-password would not have helped — it is the signup/change-password marker, so Chrome still offers to save, and additionally suggests a generated password on a field that is not one. What actually drives the heuristic is the form submit, and there is no form any more.

The header comment now states the reasoning instead of just the claim.

}

form.style.display = 'none';
Redoc.init(result.body, { hideDownloadButton: true }, document.getElementById('redoc'));

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.

Redoc 2.x renders markdown descriptions as HTML without sanitizing unless untrustedSpec is set, and DOMPurify already ships inside redoc's tree, so the option costs nothing on a page that holds a credential in memory: let's pass untrustedSpec: true to Redoc.init.

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 749ce16untrustedSpec: true on Redoc.init. The descriptions come from the agent's own schema, which is customer-authored, so it is the right default here regardless of cost.

form.style.display = 'none';
Redoc.init(result.body, { hideDownloadButton: true }, document.getElementById('redoc'));
})
.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.

Comment thread packages/agent-bff/src/cli-core.ts Outdated
...oauthMiddlewares,
// Outside the agent-scoped chain on purpose: the viewer is a public page, the document it fetches
// is not.
createDocsRoutes({ enabled: config.openapiEnabled, documentPath: OPENAPI_PATH, logger }),

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.

Without FOREST_AUTH_SECRET, buildAgentMiddlewares returns [] so no OpenAPI route is mounted, yet the docs routes are gated on config.openapiEnabled alone, so /docs serves a page whose fetch can only ever get a bare Koa 404: the exact case docs-routes.ts:51-52 says the 404 fall-through exists to avoid. Let's gate it like the error middleware two lines above, with enabled: config.openapiEnabled && agentMiddlewares.length > 0.

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 749ce16enabled: config.openapiEnabled && agentMiddlewares.length > 0, exactly as the error middleware two lines above.

Covered in test/cli-core.test.ts: with FOREST_AUTH_SECRET unset, /docs and /agent/openapi.json both answer 404.

});
});

describe('when the viewer bundle is requested without credentials', () => {

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 page's no-store is asserted at lines 52-56 but the bundle's public, max-age=3600 is not, and it is the only cache header in the package that diverges from no-store: let's assert expect(response.headers['cache-control']).toBe('public, max-age=3600') in the bundle describe.

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 749ce16expect(response.headers['cache-control']).toBe('public, max-age=3600') in the bundle describe.

@nbouliol
nbouliol force-pushed the feature/prd-687-verify-a-generated-client-calls-list-count-form-and-execute branch from c01807b to e242dc6 Compare August 19, 2026 10:03
@qltysh

qltysh Bot commented Aug 20, 2026

Copy link
Copy Markdown

1 new issue

Tool Category Rule Count
qlty Structure Function with high complexity (count = 12): createDocsRoutes 1

nbouliol and others added 3 commits August 20, 2026 15:40
Adds GET /docs and GET /docs/redoc.standalone.js, both public and both outside
the agent chain: /agent/* answers 401 to a request with no credential, and a
browser sends none when it navigates.

The page carries no schema. It asks for a BFF API key, fetches the gated
document with it, and hands the parsed object to Redoc, so the document stays
unreachable unauthenticated. The key is never persisted.

The bundle is self-hosted rather than loaded from a CDN: the page holds a
credential in memory, and a third-party script in that page could read it. redoc
is a devDependency whose bundle is copied into dist at build time, so no
consumer of the BFF installs its dependency tree.

Fixes PRD-965

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bundle lookup becomes a seam, so the state a broken build leaves behind — the
viewer disabled with a warning naming the missing file, both routes on 404 — is
asserted rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prompt was a `<form>` with no `action`, so anything that kept the inline
script from running — a CSP on the deployment is enough — turned the submit
into a navigation to `/docs?key=<the key>`: the credential in the browser
history, in the BFF access log and in every proxy on the way. A form submit is
also what Chrome reads as a login, and it offers to save the key whatever
`autocomplete` says, which broke the "never persisted" claim in the header
comment. `autocomplete="new-password"` would not have helped: it is the
signup marker, and Chrome still offers to save.

So there is no form at all. The prompt is a div, the button is a plain button,
and Enter on the input is wired explicitly — the only thing the form gave.
Without the script the button now does nothing instead of leaking.

Also on that page: `untrustedSpec` on `Redoc.init`, since the descriptions in
the document come from the agent's own schema and Redoc renders their markdown
as HTML unsanitized otherwise; and the init moved out of the fetch chain, so a
missing bundle no longer reports itself as "could not reach the document".

Two mount problems around it:

- `/docs` was gated on `openapiEnabled` alone, so an install with no
  `FOREST_AUTH_SECRET` — no agent chain, no document mounted — served a page
  whose fetch could only ever reach a bare Koa 404. Gated on the edge being
  mounted too, like the error middleware above it.
- `readFileSync` on the bundle ran outside any error handling, so a file that
  resolved at boot and became unreadable answered a bare 500 on a path no
  error middleware covers. It falls through now, like a missing bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nbouliol
nbouliol force-pushed the feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc branch from 749ce16 to 6ce3f42 Compare August 20, 2026 13:41
nbouliol and others added 2 commits August 20, 2026 16:15
The viewer was stock Redoc on a bare shell. It now carries the palette the
frontend defines in `app/styles/common/palette.css`: the lime ramp as the
accent, slate as the neutrals, dark chrome on the sidebar and the right panel
the way the product's own chrome reads.

The palette is copied into `docs-theme.ts` rather than shared — this package
depends on nothing in the frontend, and a viewer trailing a shade behind a
redesign is not a defect.

Lime 500 is the brand colour and it carries 1.96:1 against white, so it is
never text here: it is a fill, with slate 1000 on it (9.18:1). Lime 700 is the
lightest shade usable as text on white (4.54:1) and takes the links and the
accents; the dark chrome takes lime 400 (11.5:1 on slate 1000).

Inter and Source Code Pro lead the font stacks but are NOT fetched. A page
holding an API key in memory must not talk to a font CDN, for the same reason
the Redoc bundle is served from here instead of from unpkg. A machine without
them gets the system UI font, which is the price. A test now asserts the page
carries no `https?://` at all, so that reasoning is mechanical rather than
stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`info.title` is the heading Redoc renders and the name that lands in any client
generated from the document, so it is API metadata rather than page chrome —
kept in its own commit for that reason. No test asserted the old value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/agent-bff/src/docs/docs-page.ts
The frontend's `public/img/logo.svg` verbatim, minus its XML prolog — the mark
itself rather than a redrawing of it, so it cannot drift in geometry, and a
diff against the source asset stays trivial. 410 bytes, 558 once encoded.

Inline as a data URI rather than a served file: this page must request nothing
off-origin, and an icon file is a request like any other. It also needs no
route of its own and no bundle to exist, unlike everything else the page pulls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nbouliol
nbouliol force-pushed the feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc branch from 59f68d3 to 475bec7 Compare August 20, 2026 14:28
Two submissions in quick succession — a mistyped key corrected straight away —
resolve in whatever order the network gives them. Nothing checked which one was
still current, so an abandoned attempt answering late applied its result over
the live one: a stale 401 painting an error box over a rendered document, or a
stale document rendering over the one the reader actually asked for. Both
`then` and `catch` now drop completions that are not the latest attempt.

A counter rather than an AbortController: aborting fires the same `catch` that
would then need filtering anyway, so the check is the whole fix and the abort
only saves a request already in flight.

The page script had no executable test — asserting a guard by substring proves
nothing about ordering. It now runs in a `vm` against a stub DOM and a fetch
whose responses are resolved by hand, which is what lets the three race cases
be driven at all. Verified to bite: with the two checks removed, those three
fail and the other three pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/agent-bff/src/docs/docs-page.ts Outdated
…ment

A 200 whose body is not JSON — a gateway page, a truncated response — built the
`unreadable_response` placeholder and then kept `ok: response.ok`, so the
success branch handed that placeholder to `Redoc.init` as if it were a spec.
The message describing the real problem was already there and could never be
shown. The parse failure now sets `ok: false`, which is the only status that
matches what happened.

Also fixes the flake I introduced with the previous commit's harness: `flush()`
awaited a single `process.nextTick`, and the nextTick queue runs BEFORE the
microtask queue, so one tick does not settle a three-hop fetch chain. Proven
rather than guessed — a bare probe shows a 3-deep chain unsettled after
nextTick and settled after setImmediate, which is what it uses now. Three full
suite runs clean since.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants