feat(agent-bff): serve the OpenAPI document in a browser through Redoc - #1829
Conversation
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (4)
🛟 Help
|
| <body> | ||
| <form id="unlock"> | ||
| <label for="key">BFF API key</label> | ||
| <input id="key" name="key" type="password" autocomplete="off" spellcheck="false" /> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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" /> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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')); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done in 749ce16 — untrustedSpec: 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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| ...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 }), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done in 749ce16 — enabled: 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', () => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done in 749ce16 — expect(response.headers['cache-control']).toBe('public, max-age=3600') in the bundle describe.
c01807b to
e242dc6
Compare
1 new issue
|
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>
749ce16 to
6ce3f42
Compare
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>
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>
59f68d3 to
475bec7
Compare
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>
…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>

What
GET /docsrenders the BFF's dynamic OpenAPI document in a browser, with Redoc served by the BFF itself.GET /docs/redoc.standalone.jsserves the viewer bundle.Why it is shaped this way
BFF auth is header-only —
Authorization: Bearer <bff_access>orX-Forest-Bff-Key(src/auth/auth-mode-middleware.ts), no cookie anywhere in the package.resolveAuthModethrowsunauthorized()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:/agent, next to the existing public/oauth/*routes;/agent/openapi.jsonwith that header itself, and passes the parsed object toRedoc.init.The document therefore stays exactly as unreachable as before:
test/cli-core.test.tsasserts/agent/openapi.jsonstill answers 401 while/docsanswers 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
localStorageorsessionStorage, never put in a URL.Bundle provenance
redocis a devDependency; itsredoc.standalone.jsis copied intodistat build time (build:copy, the patternforest-cloudalready uses for its templates). Consequences:files: dist/**/*.jsalready covers the copied asset — no packaging change needed;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:/docsis outside the agent-scoped error middleware (src/cli-core.tswrapscreateErrorMiddlewareinagentScoped), so a thrownopenapiDisabled()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 fromsrc/openapi/— the document path is passed in fromcli-core. The existingopenapi-mount-invariant.test.tsenforces this mechanically: any such import would show up inopenapiImportsOutsideTheOpenapiDir()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 buildcopies the bundle intodist/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
/docsin agent-bff/docsthat prompts for a BFF API key, then client-side fetches the protected OpenAPI document viaX-Forest-Bff-Keyheader and renders it with Redoc.config.openapiEnabledis true and agent middlewares exist./docs/redoc.standalone.js, copied at build time intodist/docs/and cached in-memory on first request.info.title.createDocsRoutesin docs-routes.ts falls through (404) if the Redoc bundle cannot be resolved at boot or read at request time; reviewers should confirmbuild:copyin package.json runs before serve so the bundle exists underdist/docs/.Changes since #1829 opened
renderDocsPagefunction's inline script to track concurrent fetch attempts and ignore stale responses [389d25e]renderDocsPagefunction's inline script behavior using a VM sandbox environment [389d25e]docs-page.renderDocsPageresponse handling to treat unparsable JSON bodies as errors [25c20fe]Macroscope summarized 475bec7.