From c9160f532f382a3ac6bba4e189091de031556b77 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 14 Aug 2026 16:23:25 +0200 Subject: [PATCH 01/12] feat(agent-bff): serve the OpenAPI document in a browser through Redoc 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) --- packages/agent-bff/package.json | 4 +- packages/agent-bff/src/cli-core.ts | 6 +- packages/agent-bff/src/docs/docs-page.ts | 112 +++++ packages/agent-bff/src/docs/docs-routes.ts | 90 ++++ packages/agent-bff/test/cli-core.test.ts | 40 ++ .../agent-bff/test/docs/docs-routes.test.ts | 107 ++++ yarn.lock | 468 +++++++++++++++++- 7 files changed, 805 insertions(+), 22 deletions(-) create mode 100644 packages/agent-bff/src/docs/docs-page.ts create mode 100644 packages/agent-bff/src/docs/docs-routes.ts create mode 100644 packages/agent-bff/test/docs/docs-routes.test.ts diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index 97b3c1ac5c..3ce77fcc0b 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -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", @@ -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" } } diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 01732f8da2..618bd4fed5 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -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'; @@ -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'; @@ -313,6 +314,9 @@ 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. + createDocsRoutes({ enabled: config.openapiEnabled, documentPath: OPENAPI_PATH, logger }), ...agentMiddlewares, ]; const server = new BFFHttpServer({ diff --git a/packages/agent-bff/src/docs/docs-page.ts b/packages/agent-bff/src/docs/docs-page.ts new file mode 100644 index 0000000000..6d161fad80 --- /dev/null +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -0,0 +1,112 @@ +/** + * 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. + */ +export default function renderDocsPage(documentPath: string, bundlePath: string): string { + return ` + + + + + + Forest Admin BFF API + + + +
+ + + +
+
+
+ + + + +`; +} diff --git a/packages/agent-bff/src/docs/docs-routes.ts b/packages/agent-bff/src/docs/docs-routes.ts new file mode 100644 index 0000000000..e3bb8e16ef --- /dev/null +++ b/packages/agent-bff/src/docs/docs-routes.ts @@ -0,0 +1,90 @@ +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 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 { + 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, +}: DocsRoutesOptions): Middleware { + const bundle = enabled ? resolveBundle() : 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. + if (script === undefined) script = readFileSync(bundle, 'utf8'); + + ctx.status = 200; + ctx.type = 'application/javascript'; + ctx.set('Cache-Control', 'public, max-age=3600'); + ctx.body = script; + + return; + } + + ctx.status = 200; + ctx.type = 'text/html'; + ctx.set('Cache-Control', 'no-store'); + ctx.body = page; + }; +} diff --git a/packages/agent-bff/test/cli-core.test.ts b/packages/agent-bff/test/cli-core.test.ts index e147f420a5..1610b07e88 100644 --- a/packages/agent-bff/test/cli-core.test.ts +++ b/packages/agent-bff/test/cli-core.test.ts @@ -259,6 +259,46 @@ describe('runCli', () => { }); }); + describe('when the API documentation viewer is mounted', () => { + it('should serve the page and its bundle without credentials, outside the agent chain', async () => { + const server = await runCli({ ...VALID_ENV }, noopLogger); + + try { + const page = await request(server.callback).get('/docs'); + const bundle = await request(server.callback).get('/docs/redoc.standalone.js'); + + expect([page.status, bundle.status]).toEqual([200, 200]); + } finally { + await server.stop(); + } + }); + + it('should leave the document itself gated, since the public page must not have opened it', async () => { + const server = await runCli({ ...VALID_ENV }, noopLogger); + + try { + const response = await request(server.callback).get('/agent/openapi.json'); + + expect(response.status).toBe(401); + } finally { + await server.stop(); + } + }); + + it('should serve neither route when the document is disabled', async () => { + const server = await runCli({ ...VALID_ENV, BFF_OPENAPI_ENABLED: 'false' }, noopLogger); + + try { + const page = await request(server.callback).get('/docs'); + const bundle = await request(server.callback).get('/docs/redoc.standalone.js'); + + expect([page.status, bundle.status]).toEqual([404, 404]); + } finally { + await server.stop(); + } + }); + }); + describe('when a config value is malformed', () => { it('should throw ConfigurationError naming the key without echoing the secret', async () => { const err = await runCli( diff --git a/packages/agent-bff/test/docs/docs-routes.test.ts b/packages/agent-bff/test/docs/docs-routes.test.ts new file mode 100644 index 0000000000..0b033eaf37 --- /dev/null +++ b/packages/agent-bff/test/docs/docs-routes.test.ts @@ -0,0 +1,107 @@ +import type { Logger } from '../../src/ports/logger-port'; + +import Koa from 'koa'; +import request from 'supertest'; + +import createDocsRoutes, { DOCS_BUNDLE_PATH, DOCS_PATH } from '../../src/docs/docs-routes'; + +const DOCUMENT_PATH = '/agent/openapi.json'; + +const noopLogger: Logger = () => undefined; + +function buildApp(enabled: boolean, { sentinel = true }: { sentinel?: boolean } = {}): Koa { + const app = new Koa(); + app.silent = true; + app.use(createDocsRoutes({ enabled, documentPath: DOCUMENT_PATH, logger: noopLogger })); + + // Terminal sentinel: only reached when the middleware passes the request through via next(). + if (sentinel) { + app.use(async ctx => { + ctx.status = 418; + ctx.body = { passthrough: true }; + }); + } + + return app; +} + +describe('docs routes', () => { + describe('when the viewer page is requested without credentials', () => { + it('should serve it, since a browser sends no header when it navigates', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_PATH); + + expect({ status: response.status, type: response.type }).toEqual({ + status: 200, + type: 'text/html', + }); + }); + + it('should carry no schema, so a public page cannot leak the gated document', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_PATH); + + expect(response.text).not.toMatch(/"paths"|"components"|"openapi"/); + }); + + it('should point the reader at the gated document and ask for a key', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_PATH); + + expect(response.text).toContain(JSON.stringify(DOCUMENT_PATH)); + expect(response.text).toContain('X-Forest-Bff-Key'); + }); + + it('should never be cached, since the page is the entry point to a credential prompt', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_PATH); + + expect(response.headers['cache-control']).toBe('no-store'); + }); + }); + + describe('when the viewer bundle is requested without credentials', () => { + it('should serve it as a script, since the page cannot render without it', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_BUNDLE_PATH); + + expect({ status: response.status, type: response.type }).toEqual({ + status: 200, + type: 'application/javascript', + }); + }); + + it('should serve the Redoc bundle itself', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_BUNDLE_PATH); + + expect(response.text).toContain('Redoc'); + }); + }); + + describe('when the OpenAPI document is disabled', () => { + it('should fall through rather than throw, since no error middleware covers these routes', async () => { + const app = buildApp(false).callback(); + const page = await request(app).get(DOCS_PATH); + const bundle = await request(app).get(DOCS_BUNDLE_PATH); + + expect([page.status, bundle.status]).toEqual([418, 418]); + }); + + it('should leave both routes answering 404, so nothing advertises a page it does not serve', async () => { + const app = buildApp(false, { sentinel: false }).callback(); + const page = await request(app).get(DOCS_PATH); + const bundle = await request(app).get(DOCS_BUNDLE_PATH); + + expect([page.status, bundle.status]).toEqual([404, 404]); + }); + }); + + describe('when the path or the method is not the viewer', () => { + it('should pass a non-docs path through', async () => { + const response = await request(buildApp(true).callback()).get('/health'); + + expect(response.status).toBe(418); + }); + + it('should pass a write on the viewer path through, since it only serves reads', async () => { + const response = await request(buildApp(true).callback()).post(DOCS_PATH); + + expect(response.status).toBe(418); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3dfb58df22..8bec0d8b44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1179,6 +1179,11 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" +"@babel/runtime@^7.17.8": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" + integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== + "@babel/runtime@^7.18.3": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b" @@ -1640,6 +1645,11 @@ dependencies: heap ">= 0.2.0" +"@exodus/schemasafe@^1.0.0-rc.2": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@exodus/schemasafe/-/schemasafe-1.3.0.tgz#731656abe21e8e769a7f70a4d833e6312fe59b7f" + integrity sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw== + "@faker-js/faker@^7.6.0": version "7.6.0" resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-7.6.0.tgz#9ea331766084288634a9247fcd8b84f16ff4ba07" @@ -2783,6 +2793,11 @@ resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.1.0.tgz#f543e5c6446720d4cf9e498a83019dd159973bc2" integrity sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA== +"@nodable/entities@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-3.0.0.tgz#694703bc864d30eaed55c2e3def00dbd61493670" + integrity sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -3492,11 +3507,41 @@ signale "^1.4.0" stream-buffers "^3.0.2" +"@redocly/ajv@8.11.2": + version "8.11.2" + resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.11.2.tgz#46e1bf321ec0ac1e0fd31dea41a3d1fcbdcda0b5" + integrity sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js-replace "^1.0.1" + "@redocly/cli@2.35.1": version "2.35.1" resolved "https://registry.yarnpkg.com/@redocly/cli/-/cli-2.35.1.tgz#391392a0857ce009990c5ff2eebe9066460f0795" integrity sha512-8XcUIR6bCI4KmVg6RJyzL3peZhld/tu7oO8WGVaHp43byhcds6ProHlfqEFa+dZA+qA+dUMebgRELVOe5AW4Lg== +"@redocly/config@0.22.0": + version "0.22.0" + resolved "https://registry.yarnpkg.com/@redocly/config/-/config-0.22.0.tgz#b2454f472f9d2b217d56f82e4f28d346901f3242" + integrity sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ== + +"@redocly/openapi-core@^1.34.15": + version "1.34.19" + resolved "https://registry.yarnpkg.com/@redocly/openapi-core/-/openapi-core-1.34.19.tgz#9b1877a44a588a37b196beff0b86a40386c02779" + integrity sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw== + dependencies: + "@redocly/ajv" "8.11.2" + "@redocly/config" "0.22.0" + colorette "1.4.0" + https-proxy-agent "7.0.6" + js-levenshtein "1.1.6" + js-yaml "4.3.1" + minimatch "5.1.9" + pluralize "8.0.0" + yaml-ast-parser "0.0.43" + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" @@ -4617,7 +4662,7 @@ resolved "https://registry.yarnpkg.com/@types/json-api-serializer/-/json-api-serializer-2.6.6.tgz#26b5381214aa19bb98a6931fe41c3a336fc7f169" integrity sha512-8XVIVyMNoFMz3pfR3tPHnJ9YlgUQDEWvTxajVakmOjSxWekJvmi2GRFbtaREQiOGtffnHImD0jbR80NQtpib9g== -"@types/json-schema@*", "@types/json-schema@7.0.15", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@7.0.15", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -4869,6 +4914,11 @@ "@types/methods" "^1.1.4" "@types/superagent" "^8.1.0" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/unist@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" @@ -5315,6 +5365,11 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +anynum@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/anynum/-/anynum-1.0.1.tgz#2aac00e08dfad3726c1d462e60dbc2f831659a44" + integrity sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A== + apollo-cache-inmemory@^1.6.6: version "1.6.6" resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.6.tgz#56d1f2a463a6b9db32e9fa990af16d2a008206fd" @@ -6146,6 +6201,11 @@ call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" +call-me-maybe@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" + integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ== + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -6353,6 +6413,11 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ== +classnames@^2.3.2: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + clean-regexp@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/clean-regexp/-/clean-regexp-1.0.0.tgz#8df7c7aae51fd36874e8f8d05b9180bc11a3fed7" @@ -6513,6 +6578,11 @@ clone@1.0.4, clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== +clsx@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + cmd-shim@6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-6.0.3.tgz#c491e9656594ba17ac83c4bd931590a9d6e26033" @@ -6594,6 +6664,11 @@ color@^4.2.3: color-convert "^2.0.1" color-string "^1.9.0" +colorette@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" + integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== + colorette@2.0.19: version "2.0.19" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" @@ -7144,6 +7219,11 @@ decamelize@^1.1.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== +decko@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decko/-/decko-1.2.0.tgz#fd43c735e967b8013306884a56fbe665996b6817" + integrity sha512-m8FnyHXV1QX+S1cl+KPFDIl6NMkxtKsy6+U/aYyjrOqWMuwAwYWu7ePqrsUHtDR5Y8Yk2pi/KIDSgF+vT4cPOQ== + decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -7378,6 +7458,13 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" +dompurify@^3.2.4: + version "3.4.14" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.14.tgz#a789edb2c7bcdb69a93713a34edb7e0a5245d8c9" + integrity sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + domutils@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" @@ -7778,6 +7865,11 @@ es6-iterator@^2.0.3: es5-ext "^0.10.35" es6-symbol "^3.1.1" +es6-promise@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" + integrity sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg== + es6-symbol@^3.1.1, es6-symbol@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" @@ -8599,6 +8691,18 @@ fast-xml-parser@5.5.8, fast-xml-parser@^5.7.0: strnum "^2.3.0" xml-naming "^0.1.0" +fast-xml-parser@^5.5.1: + version "5.11.0" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.11.0.tgz#7cd9ea0e34c15619c1af0c7e2d8e183c891ee832" + integrity sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw== + dependencies: + "@nodable/entities" "^3.0.0" + fast-xml-builder "^1.2.0" + is-unsafe "^2.0.0" + path-expression-matcher "^1.6.2" + strnum "^2.4.2" + xml-naming "^0.3.0" + fastest-levenshtein@^1.0.16, fastest-levenshtein@^1.0.7: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -8940,6 +9044,11 @@ for-each@^0.3.5: dependencies: is-callable "^1.2.7" +foreach@^2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.6.tgz#87bcc8a1a0e74000ff2bf9802110708cfb02eb6e" + integrity sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg== + forest-cli@5.3.9: version "5.3.9" resolved "https://registry.yarnpkg.com/forest-cli/-/forest-cli-5.3.9.tgz#bcb628bf5f156145064c2d5e4b6eae33f008550c" @@ -9798,6 +9907,19 @@ http-proxy-agent@^7.0.0: agent-base "^7.1.0" debug "^4.3.4" +http2-client@^1.2.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/http2-client/-/http2-client-1.3.5.tgz#20c9dc909e3cc98284dd20af2432c524086df181" + integrity sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA== + +https-proxy-agent@7.0.6, https-proxy-agent@^7.0.1: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -9814,14 +9936,6 @@ https-proxy-agent@^7.0.0: agent-base "^7.0.2" debug "4" -https-proxy-agent@^7.0.1: - version "7.0.6" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" - integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== - dependencies: - agent-base "^7.1.2" - debug "4" - human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -10627,6 +10741,11 @@ is-unicode-supported@^2.0.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== +is-unsafe@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-unsafe/-/is-unsafe-2.0.2.tgz#bb1ead17f1aa688f6433258b561e98b1a45a1afc" + integrity sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ== + is-weakmap@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" @@ -11191,6 +11310,11 @@ jose@^6.1.3: resolved "https://registry.yarnpkg.com/jose/-/jose-6.1.3.tgz#8453d7be88af7bb7d64a0481d6a35a0145ba3ea5" integrity sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ== +js-levenshtein@1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + js-md4@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/js-md4/-/js-md4-0.3.2.tgz#cd3b3dc045b0c404556c81ddb5756c23e59d7cf5" @@ -11203,12 +11327,12 @@ js-tiktoken@^1.0.12: dependencies: base64-js "^1.5.1" -js-tokens@^4.0.0: +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@4.1.1, js-yaml@^4.1.0, js-yaml@^4.3.1: +js-yaml@4.1.1, js-yaml@4.3.1, js-yaml@^4.1.0, js-yaml@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848" integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ== @@ -11286,6 +11410,13 @@ json-parse-even-better-errors@^5.0.0: resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz#93c89f529f022e5dadc233409324f0167b1e903e" integrity sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ== +json-pointer@0.6.2, json-pointer@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/json-pointer/-/json-pointer-0.6.2.tgz#f97bd7550be5e9ea901f8c9264c9d436a22a93cd" + integrity sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw== + dependencies: + foreach "^2.0.4" + json-schema-ref-resolver@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz#6586f483b76254784fc1d2120f717bdc9f0a99bf" @@ -12135,6 +12266,13 @@ longest-streak@^2.0.0: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== +loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + lru-cache@^10.0.1: version "10.0.2" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.2.tgz#34504678cc3266b09b8dfd6fab4e1515258271b7" @@ -12323,6 +12461,11 @@ mariadb@^3.0.2: iconv-lite "^0.6.3" lru-cache "^10.0.1" +mark.js@^8.11.1: + version "8.11.1" + resolved "https://registry.yarnpkg.com/mark.js/-/mark.js-8.11.1.tgz#180f1f9ebef8b0e638e4166ad52db879beb2ffc5" + integrity sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ== + markdown-it@^14.1.1: version "14.3.0" resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.3.0.tgz#8542fa5506e3530f7e2b08dc3885630135c5620e" @@ -12382,7 +12525,7 @@ marked@^15.0.0: resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.12.tgz#30722c7346e12d0a2d0207ab9b0c4f0102d86c4e" integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA== -marked@^4.1.0: +marked@^4.1.0, marked@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== @@ -12729,6 +12872,13 @@ minimatch@3.1.4: dependencies: brace-expansion "^1.1.7" +minimatch@5.1.9, minimatch@^5.0.1: + version "5.1.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" + integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== + dependencies: + brace-expansion "^2.0.1" + minimatch@^10.0.3, minimatch@^10.1.1, minimatch@^10.2.4: version "10.2.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" @@ -12743,13 +12893,6 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1: - version "5.1.9" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" - integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== - dependencies: - brace-expansion "^2.0.1" - minimatch@^9.0.5: version "9.0.9" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" @@ -12886,6 +13029,20 @@ mkdirp@1.0.4, mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mobx-react-lite@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz#725d74b025235f73dc2ab815766ffe010be2cf8a" + integrity sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg== + dependencies: + use-sync-external-store "^1.4.0" + +mobx-react@9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-9.2.0.tgz#c1e4d1ed406f6664d9de0787c948bac3a7ed5893" + integrity sha512-dkGWCx+S0/1mfiuFfHRH8D9cplmwhxOV5CkXMp38u6rQGG2Pv3FWYztS0M7ncR6TyPRQKaTG/pnitInoYE9Vrw== + dependencies: + mobx-react-lite "^4.1.0" + modify-values@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -13169,7 +13326,14 @@ node-emoji@^2.2.0: emojilib "^2.4.0" skin-tone "^2.0.0" -node-fetch@^2.3.0, node-fetch@^2.6.7, node-fetch@^2.7.0: +node-fetch-h2@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz#c6188325f9bd3d834020bf0f2d6dc17ced2241ac" + integrity sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg== + dependencies: + http2-client "^1.2.5" + +node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -13245,6 +13409,13 @@ node-mocks-http@^1.5.8: range-parser "^1.2.0" type-is "^1.6.18" +node-readfiles@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/node-readfiles/-/node-readfiles-0.2.0.tgz#dbbd4af12134e2e635c245ef93ffcf6f60673a5d" + integrity sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA== + dependencies: + es6-promise "^3.2.1" + node-releases@^2.0.21: version "2.0.21" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.21.tgz#f59b018bc0048044be2d4c4c04e4c8b18160894c" @@ -13746,6 +13917,52 @@ nth-check@^2.0.1: "@nx/nx-win32-arm64-msvc" "22.7.7" "@nx/nx-win32-x64-msvc" "22.7.7" +oas-kit-common@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/oas-kit-common/-/oas-kit-common-1.0.8.tgz#6d8cacf6e9097967a4c7ea8bcbcbd77018e1f535" + integrity sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ== + dependencies: + fast-safe-stringify "^2.0.7" + +oas-linter@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/oas-linter/-/oas-linter-3.2.2.tgz#ab6a33736313490659035ca6802dc4b35d48aa1e" + integrity sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ== + dependencies: + "@exodus/schemasafe" "^1.0.0-rc.2" + should "^13.2.1" + yaml "^1.10.0" + +oas-resolver@^2.5.6: + version "2.5.6" + resolved "https://registry.yarnpkg.com/oas-resolver/-/oas-resolver-2.5.6.tgz#10430569cb7daca56115c915e611ebc5515c561b" + integrity sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ== + dependencies: + node-fetch-h2 "^2.3.0" + oas-kit-common "^1.0.8" + reftools "^1.1.9" + yaml "^1.10.0" + yargs "^17.0.1" + +oas-schema-walker@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz#74c3cd47b70ff8e0b19adada14455b5d3ac38a22" + integrity sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ== + +oas-validator@^5.0.8: + version "5.0.8" + resolved "https://registry.yarnpkg.com/oas-validator/-/oas-validator-5.0.8.tgz#387e90df7cafa2d3ffc83b5fb976052b87e73c28" + integrity sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw== + dependencies: + call-me-maybe "^1.0.1" + oas-kit-common "^1.0.8" + oas-linter "^3.2.2" + oas-resolver "^2.5.6" + oas-schema-walker "^1.1.5" + reftools "^1.1.9" + should "^13.2.1" + yaml "^1.10.0" + object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -13930,6 +14147,15 @@ openai@^6.37.0: resolved "https://registry.yarnpkg.com/openai/-/openai-6.42.0.tgz#497bb98294a2aadcc90a655736c1d43c32ffd5d9" integrity sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg== +openapi-sampler@^1.6.2: + version "1.7.4" + resolved "https://registry.yarnpkg.com/openapi-sampler/-/openapi-sampler-1.7.4.tgz#453c50aa6fa8b1d02cb83a399c5aad965deb0e79" + integrity sha512-CKS/rd5ucPCuEDbJnjGDXZTsuGWcmv53aCmQx7soZlPEONUGN4af0/dY5+THRFZraSEjeA78nlfzdFswC/N5SA== + dependencies: + "@types/json-schema" "^7.0.7" + fast-xml-parser "^5.5.1" + json-pointer "0.6.2" + openapi3-ts@4.6.1, openapi3-ts@^4.1.2: version "4.6.1" resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-4.6.1.tgz#aaabcab1cf1d17cf754fb49f3041f3cf808e1f38" @@ -14372,6 +14598,11 @@ password-prompt@^1.1.3: ansi-escapes "^4.3.2" cross-spawn "^7.0.3" +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -14387,6 +14618,11 @@ path-expression-matcher@^1.5.0: resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a" integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ== +path-expression-matcher@^1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz#567c73c07197e9dcef24e90edcdc571056599168" + integrity sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -14475,6 +14711,11 @@ perfect-debounce@^2.1.0: resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz#e7078e38f231cb191855c3136a4423aef725d261" integrity sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g== +perfect-scrollbar@^1.5.5: + version "1.5.6" + resolved "https://registry.yarnpkg.com/perfect-scrollbar/-/perfect-scrollbar-1.5.6.tgz#f1aead2588ba896435ee41b246812b2080573b7c" + integrity sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw== + pg-cloudflare@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" @@ -14723,6 +14964,13 @@ pluralize@8.0.0, pluralize@^8.0.0: resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== +polished@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" + integrity sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA== + dependencies: + "@babel/runtime" "^7.17.8" + pony-cause@^2.1.4: version "2.1.11" resolved "https://registry.yarnpkg.com/pony-cause/-/pony-cause-2.1.11.tgz#d69a20aaccdb3bdb8f74dd59e5c68d8e6772e4bd" @@ -14857,6 +15105,11 @@ pretty-ms@^9.2.0: dependencies: parse-ms "^4.0.0" +prismjs@^1.29.0: + version "1.30.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9" + integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw== + proc-log@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-5.0.0.tgz#e6c93cf37aef33f835c53485f314f50ea906a9d8" @@ -14962,6 +15215,15 @@ promzard@^3.0.1: dependencies: read "^5.0.0" +prop-types@^15.5.0, prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + propagate@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" @@ -15120,11 +15382,24 @@ rc@^1.2.7, rc@^1.2.8: resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.7.tgz#57668ee86a78574a542b0a539455212b2c086df2" integrity sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A== +react-is@^16.13.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + react-is@^18.0.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-tabs@^6.0.2: + version "6.1.1" + resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-6.1.1.tgz#c56ff0f4e3efb09caa98e0ff456aee6cf87bf2b0" + integrity sha512-CPiuKoMFf89B7QlbFfdBD9XmUWiE3qudQputMVZB8GQvPJZRX/gqjDaDWOPDwGinEfpJKEuBCkGt83Tt4efeyA== + dependencies: + clsx "^2.0.0" + prop-types "^15.5.0" + read-cmd-shim@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz#640a08b473a49043e394ae0c7a34dd822c73b9bb" @@ -15307,6 +15582,33 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" +redoc@2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/redoc/-/redoc-2.5.3.tgz#f44692cbdf81bb2077fb38358885d976e6e27f88" + integrity sha512-bBbat+Sx6xKWdyoCGTtA0BWeTEW9Vs4VnEja7q7ZLOk4IM7cHQLrf+kDxWF6dKeKxT8kOBnoy/OsNXCeLttpyQ== + dependencies: + "@redocly/openapi-core" "^1.34.15" + classnames "^2.3.2" + decko "^1.2.0" + dompurify "^3.2.4" + eventemitter3 "^5.0.1" + json-pointer "^0.6.2" + lunr "^2.3.9" + mark.js "^8.11.1" + marked "^4.3.0" + mobx-react "9.2.0" + openapi-sampler "^1.6.2" + path-browserify "^1.0.1" + perfect-scrollbar "^1.5.5" + polished "^4.2.2" + prismjs "^1.29.0" + prop-types "^15.8.1" + react-tabs "^6.0.2" + slugify "~1.4.7" + stickyfill "^1.1.1" + swagger2openapi "^7.0.8" + url-template "^2.0.8" + reflect-metadata@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" @@ -15326,6 +15628,11 @@ reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: get-proto "^1.0.1" which-builtin-type "^1.2.1" +reftools@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/reftools/-/reftools-1.1.9.tgz#e16e19f662ccd4648605312c06d34e5da3a2b77e" + integrity sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w== + regexp-tree@^0.1.27: version "0.1.27" resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.27.tgz#2198f0ef54518ffa743fe74d983b56ffd631b6cd" @@ -16030,6 +16337,50 @@ shell-quote@1.8.3, shell-quote@^1.8.4: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.9.0.tgz#e108b1a136586d5964edb3300016d4bedba0fe57" integrity sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA== +should-equal@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" + integrity sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA== + dependencies: + should-type "^1.4.0" + +should-format@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/should-format/-/should-format-3.0.3.tgz#9bfc8f74fa39205c53d38c34d717303e277124f1" + integrity sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q== + dependencies: + should-type "^1.3.0" + should-type-adaptors "^1.0.1" + +should-type-adaptors@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz#401e7f33b5533033944d5cd8bf2b65027792e27a" + integrity sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA== + dependencies: + should-type "^1.3.0" + should-util "^1.0.0" + +should-type@^1.3.0, should-type@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/should-type/-/should-type-1.4.0.tgz#0756d8ce846dfd09843a6947719dfa0d4cff5cf3" + integrity sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ== + +should-util@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/should-util/-/should-util-1.0.1.tgz#fb0d71338f532a3a149213639e2d32cbea8bcb28" + integrity sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g== + +should@^13.2.1: + version "13.2.3" + resolved "https://registry.yarnpkg.com/should/-/should-13.2.3.tgz#96d8e5acf3e97b49d89b51feaa5ae8d07ef58f10" + integrity sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ== + dependencies: + should-equal "^2.0.0" + should-format "^3.0.3" + should-type "^1.4.0" + should-type-adaptors "^1.0.1" + should-util "^1.0.0" + side-channel-list@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" @@ -16182,6 +16533,11 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" +slugify@~1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.4.7.tgz#e42359d505afd84a44513280868e31202a79a628" + integrity sha512-tf+h5W1IrjNm/9rKKj0JU2MDMruiopx0jjVA5zCdBtcGjfp0+c5rHw/zADLC3IeKlGHtVbHtpfzvYA0OYT+HKg== + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" @@ -16466,6 +16822,11 @@ stdout-stderr@0.1.13: debug "^4.1.1" strip-ansi "^6.0.0" +stickyfill@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stickyfill/-/stickyfill-1.1.1.tgz#39413fee9d025c74a7e59ceecb23784cc0f17f02" + integrity sha512-GCp7vHAfpao+Qh/3Flh9DXEJ/qSi0KJwJw6zYlZOtRYXWUIpMM6mC2rIep/dK8RQqwW0KxGJIllmjPIBOGN8AA== + stop-iteration-iterator@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" @@ -16701,6 +17062,13 @@ strnum@^2.3.0: resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.3.0.tgz#81bfbfef53db8c3217ea62a98c026886ec4a2761" integrity sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q== +strnum@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.4.2.tgz#af43ab51a06d04227023fc2e42ca229214ec10f0" + integrity sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw== + dependencies: + anynum "^1.0.1" + strtok3@^10.3.4: version "10.3.5" resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-10.3.5.tgz#7213285da0dc3dec0fc8ce5df4b8b7a733f14360" @@ -16818,6 +17186,23 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swagger2openapi@^7.0.8: + version "7.0.8" + resolved "https://registry.yarnpkg.com/swagger2openapi/-/swagger2openapi-7.0.8.tgz#12c88d5de776cb1cbba758994930f40ad0afac59" + integrity sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g== + dependencies: + call-me-maybe "^1.0.1" + node-fetch "^2.6.1" + node-fetch-h2 "^2.3.0" + node-readfiles "^0.2.0" + oas-kit-common "^1.0.8" + oas-resolver "^2.5.6" + oas-schema-walker "^1.1.5" + oas-validator "^5.0.8" + reftools "^1.1.9" + yaml "^1.10.0" + yargs "^17.0.1" + symbol-observable@^1.0.2, symbol-observable@^1.0.4: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" @@ -17720,6 +18105,11 @@ update-browserslist-db@^1.1.3: escalade "^3.2.0" picocolors "^1.1.1" +uri-js-replace@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uri-js-replace/-/uri-js-replace-1.0.1.tgz#c285bb352b701c9dfdaeffc4da5be77f936c9048" + integrity sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g== + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -17732,6 +18122,16 @@ url-join@^5.0.0: resolved "https://registry.yarnpkg.com/url-join/-/url-join-5.0.0.tgz#c2f1e5cbd95fa91082a93b58a1f42fecb4bdbcf1" integrity sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA== +url-template@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" + integrity sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw== + +use-sync-external-store@^1.4.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + util-deprecate@1.0.2, util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -18117,6 +18517,11 @@ xml-naming@^0.1.0: resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.1.0.tgz#8ab7106c5b8d23caa2fabac1cadf17136379fbd8" integrity sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw== +xml-naming@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.3.0.tgz#46c1e18bfe2858479982dd2accf34d16e749eda2" + integrity sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ== + xmlbuilder@^15.1.1: version "15.1.1" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" @@ -18147,11 +18552,21 @@ yallist@^5.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== +yaml-ast-parser@0.0.43: + version "0.0.43" + resolved "https://registry.yarnpkg.com/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" + integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== + yaml@2.9.0, yaml@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== +yaml@^1.10.0: + version "1.10.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3" + integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA== + yaml@^2.8.2: version "2.8.3" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" @@ -18198,6 +18613,19 @@ yargs@^16.0.0, yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" +yargs@^17.0.1: + version "17.7.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" + integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yargs@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1" From 6518b88f9b98c19cece83ae5101f8223c38cf23b Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 14 Aug 2026 16:37:54 +0200 Subject: [PATCH 02/12] test(agent-bff): cover the install whose Redoc bundle never shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/agent-bff/src/docs/docs-routes.ts | 7 +++- .../agent-bff/test/docs/docs-routes.test.ts | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/agent-bff/src/docs/docs-routes.ts b/packages/agent-bff/src/docs/docs-routes.ts index e3bb8e16ef..eede6efe96 100644 --- a/packages/agent-bff/src/docs/docs-routes.ts +++ b/packages/agent-bff/src/docs/docs-routes.ts @@ -17,6 +17,8 @@ export interface DocsRoutesOptions { /** 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; } /** @@ -32,6 +34,8 @@ function resolveBundle(): string | undefined { 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; } } @@ -51,8 +55,9 @@ export default function createDocsRoutes({ enabled, documentPath, logger, + resolveBundlePath = resolveBundle, }: DocsRoutesOptions): Middleware { - const bundle = enabled ? resolveBundle() : undefined; + const bundle = enabled ? resolveBundlePath() : undefined; if (enabled && !bundle) { logger('Warn', `API documentation page disabled: ${BUNDLE_FILE} is missing from this install`); diff --git a/packages/agent-bff/test/docs/docs-routes.test.ts b/packages/agent-bff/test/docs/docs-routes.test.ts index 0b033eaf37..d116f23a70 100644 --- a/packages/agent-bff/test/docs/docs-routes.test.ts +++ b/packages/agent-bff/test/docs/docs-routes.test.ts @@ -91,6 +91,43 @@ describe('docs routes', () => { }); }); + describe('when the install has no Redoc bundle', () => { + function buildBundlelessApp(logger: Logger): Koa { + const app = new Koa(); + app.silent = true; + app.use( + createDocsRoutes({ + enabled: true, + documentPath: DOCUMENT_PATH, + logger, + resolveBundlePath: () => undefined, + }), + ); + + return app; + } + + it('should serve neither route, since a page without its viewer renders nothing', async () => { + const app = buildBundlelessApp(noopLogger).callback(); + const page = await request(app).get(DOCS_PATH); + const bundle = await request(app).get(DOCS_BUNDLE_PATH); + + expect([page.status, bundle.status]).toEqual([404, 404]); + }); + + it('should say why at boot, since a silent 404 looks like a disabled flag', async () => { + const logs: string[] = []; + + buildBundlelessApp((_level, message) => { + logs.push(message); + }); + + expect(logs).toEqual([ + 'API documentation page disabled: redoc.standalone.js is missing from this install', + ]); + }); + }); + describe('when the path or the method is not the viewer', () => { it('should pass a non-docs path through', async () => { const response = await request(buildApp(true).callback()).get('/health'); From 9c7f85fcc4e9c1827cd26979dcb07de223b5070c Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 20 Aug 2026 14:57:37 +0200 Subject: [PATCH 03/12] fix(agent-bff): stop the docs page from ever putting the key in a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt was a `
` 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 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) --- packages/agent-bff/src/cli-core.ts | 9 ++- packages/agent-bff/src/docs/docs-page.ts | 58 +++++++++++++++---- packages/agent-bff/src/docs/docs-routes.ts | 15 ++++- packages/agent-bff/test/cli-core.test.ts | 13 +++++ .../agent-bff/test/docs/docs-routes.test.ts | 49 ++++++++++++++++ 5 files changed, 130 insertions(+), 14 deletions(-) diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 618bd4fed5..a8caffa19e 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -315,8 +315,13 @@ export default async function runCli( bodyParser({ jsonLimit: BODY_LIMIT }), ...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 }), + // 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({ diff --git a/packages/agent-bff/src/docs/docs-page.ts b/packages/agent-bff/src/docs/docs-page.ts index 6d161fad80..e2a4347bbb 100644 --- a/packages/agent-bff/src/docs/docs-page.ts +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -6,6 +6,13 @@ * * 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 ``. A form with no `action` navigates to `/docs?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. */ export default function renderDocsPage(documentPath: string, bundlePath: string): string { return ` @@ -26,19 +33,21 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) - +
- - - + + +
diff --git a/packages/agent-bff/src/docs/docs-routes.ts b/packages/agent-bff/src/docs/docs-routes.ts index eede6efe96..d2d87998ff 100644 --- a/packages/agent-bff/src/docs/docs-routes.ts +++ b/packages/agent-bff/src/docs/docs-routes.ts @@ -76,8 +76,19 @@ export default function createDocsRoutes({ } if (ctx.path === DOCS_BUNDLE_PATH) { - // Read once and kept in memory: ~1 MB, served on every page load. - if (script === undefined) script = readFileSync(bundle, 'utf8'); + // 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'; diff --git a/packages/agent-bff/test/cli-core.test.ts b/packages/agent-bff/test/cli-core.test.ts index 1610b07e88..7396571f2b 100644 --- a/packages/agent-bff/test/cli-core.test.ts +++ b/packages/agent-bff/test/cli-core.test.ts @@ -297,6 +297,19 @@ describe('runCli', () => { await server.stop(); } }); + + it('should serve neither route when the agent edge is not mounted, since no document is served', async () => { + const server = await runCli({ ...VALID_ENV, FOREST_AUTH_SECRET: undefined }, noopLogger); + + try { + const page = await request(server.callback).get('/docs'); + const document = await request(server.callback).get('/agent/openapi.json'); + + expect([page.status, document.status]).toEqual([404, 404]); + } finally { + await server.stop(); + } + }); }); describe('when a config value is malformed', () => { diff --git a/packages/agent-bff/test/docs/docs-routes.test.ts b/packages/agent-bff/test/docs/docs-routes.test.ts index d116f23a70..04cae102b9 100644 --- a/packages/agent-bff/test/docs/docs-routes.test.ts +++ b/packages/agent-bff/test/docs/docs-routes.test.ts @@ -1,6 +1,7 @@ import type { Logger } from '../../src/ports/logger-port'; import Koa from 'koa'; +import path from 'path'; import request from 'supertest'; import createDocsRoutes, { DOCS_BUNDLE_PATH, DOCS_PATH } from '../../src/docs/docs-routes'; @@ -49,6 +50,12 @@ describe('docs routes', () => { expect(response.text).toContain('X-Forest-Bff-Key'); }); + it('should carry no form, since a default submit would put the key in the URL', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_PATH); + + expect(response.text).not.toContain(' { const response = await request(buildApp(true).callback()).get(DOCS_PATH); @@ -71,6 +78,48 @@ describe('docs routes', () => { expect(response.text).toContain('Redoc'); }); + + it('should be cacheable, since it is a versioned third-party asset and not a credential path', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_BUNDLE_PATH); + + expect(response.headers['cache-control']).toBe('public, max-age=3600'); + }); + }); + + describe('when the bundle resolved at boot but cannot be read', () => { + function buildUnreadableBundleApp(logs: string[]): Koa { + const app = new Koa(); + app.silent = true; + app.use( + createDocsRoutes({ + enabled: true, + documentPath: DOCUMENT_PATH, + logger: (_level, message) => logs.push(message), + resolveBundlePath: () => path.join(__dirname, 'no-such-bundle.js'), + }), + ); + app.use(async ctx => { + ctx.status = 418; + }); + + return app; + } + + it('should fall through rather than answer a bare 500, since no error middleware covers it', async () => { + const response = await request(buildUnreadableBundleApp([]).callback()).get(DOCS_BUNDLE_PATH); + + expect(response.status).toBe(418); + }); + + it('should say which file it could not read', async () => { + const logs: string[] = []; + + await request(buildUnreadableBundleApp(logs).callback()).get(DOCS_BUNDLE_PATH); + + expect(logs).toEqual([ + `API documentation bundle unreadable: ${path.join(__dirname, 'no-such-bundle.js')}`, + ]); + }); }); describe('when the OpenAPI document is disabled', () => { From 8fb4a191583620becc27e2fefdf768da41b12dcc Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 20 Aug 2026 16:15:08 +0200 Subject: [PATCH 04/12] feat(agent-bff): theme the docs viewer with the Forest palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/agent-bff/src/docs/docs-page.ts | 32 +++---- packages/agent-bff/src/docs/docs-theme.ts | 96 +++++++++++++++++++ .../agent-bff/test/docs/docs-routes.test.ts | 14 +++ 3 files changed, 124 insertions(+), 18 deletions(-) create mode 100644 packages/agent-bff/src/docs/docs-theme.ts diff --git a/packages/agent-bff/src/docs/docs-page.ts b/packages/agent-bff/src/docs/docs-page.ts index e2a4347bbb..b4f8aec634 100644 --- a/packages/agent-bff/src/docs/docs-page.ts +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -14,6 +14,14 @@ * form there is no default action to prevent and no submit to observe: without this script the button * does nothing at all. */ +import { 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 ` @@ -21,19 +29,12 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) - Forest Admin BFF API - + Forest BFF API +
+ Forest. @@ -45,6 +46,7 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) (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'); @@ -71,9 +73,7 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) /** * 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. \`untrustedSpec\` - * because the descriptions in the document come from the agent's own schema, and Redoc renders - * their markdown as HTML — unsanitized unless it is told the spec is untrusted. + * "could not reach the document" would point the reader at the wrong thing. */ function render(spec) { if (typeof Redoc === 'undefined') { @@ -85,11 +85,7 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) unlock.style.display = 'none'; try { - Redoc.init( - spec, - { hideDownloadButton: true, untrustedSpec: true }, - document.getElementById('redoc'), - ); + Redoc.init(spec, REDOC_OPTIONS, document.getElementById('redoc')); } catch (initError) { unlock.style.display = ''; show('The Redoc viewer could not render the document: ' + initError); diff --git a/packages/agent-bff/src/docs/docs-theme.ts b/packages/agent-bff/src/docs/docs-theme.ts new file mode 100644 index 0000000000..da53914581 --- /dev/null +++ b/packages/agent-bff/src/docs/docs-theme.ts @@ -0,0 +1,96 @@ +/** + * The Forest palette, as the frontend defines it in `app/styles/common/palette.css`: `accent` is the + * lime ramp, the neutrals are `slate`. Copied rather than shared — this package depends on nothing in + * the frontend, and a viewer that trails a shade behind a redesign is not a defect. + * + * Only the shades this page uses are here. The pairings below are the accessible ones: lime 500 is + * the brand colour but it carries 1.96:1 against white, so it is a FILL with dark text on it, never + * text itself. Lime 700 (4.54:1) is the lightest shade usable as text on white, and the dark chrome + * takes lime 400 (11.5:1 on slate 1000). + */ +const LIME = { + 400: '#afdf3c', + 500: '#99c924', + 600: '#7ba01f', + 700: '#62801a', + 800: '#496015', +} as const; + +const SLATE = { + 100: '#f0f1f3', + 200: '#e0e3e8', + 300: '#c1c7d1', + 500: '#8390a2', + 700: '#505d6f', + 900: '#282e38', + 1000: '#14171c', +} as const; + +const RED = { 100: '#fdecec', 700: '#bf3636' } as const; +const EMERALD = { 600: '#10b981' } as const; +const YELLOW = { 600: '#f59e0b' } as const; +const BLUE = { 600: '#3b82f6' } as const; + +/** + * Inter and Source Code Pro are the frontend's faces, first in the stack so a machine that has them + * uses them. They 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 rather than from one. Everyone else gets + * the system UI font, which is the price of that. + */ +const SANS = "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; +const MONO = "'Source Code Pro', ui-monospace, SFMono-Regular, Menlo, monospace"; + +/** Passed to `Redoc.init`. Unknown keys are merged into Redoc's own defaults, so this is additive. */ +export const REDOC_THEME = { + colors: { + primary: { main: LIME[700] }, + success: { main: EMERALD[600] }, + warning: { main: YELLOW[600] }, + error: { main: RED[700] }, + text: { primary: SLATE[1000], secondary: SLATE[700] }, + border: { light: SLATE[200], dark: SLATE[300] }, + http: { + get: LIME[600], + post: EMERALD[600], + put: BLUE[600], + patch: YELLOW[600], + delete: RED[700], + options: SLATE[700], + head: SLATE[700], + basic: SLATE[700], + link: BLUE[600], + }, + }, + typography: { + fontSize: '15px', + lineHeight: '1.6', + fontFamily: SANS, + headings: { fontFamily: SANS, fontWeight: '600' }, + code: { fontFamily: MONO, fontSize: '13px' }, + links: { color: LIME[700], visited: LIME[700], hover: LIME[800] }, + }, + // Forest's own chrome is dark with lime accents; Redoc's two panels are where that reads. + sidebar: { + backgroundColor: SLATE[1000], + textColor: SLATE[300], + activeTextColor: LIME[400], + arrow: { color: SLATE[500] }, + }, + rightPanel: { backgroundColor: SLATE[900], textColor: SLATE[100] }, + schema: { typeNameColor: SLATE[700], typeTitleColor: LIME[700], requireLabelColor: RED[700] }, +} as const; + +/** The shell around Redoc: the key prompt, and the error box it writes into. */ +export const PAGE_STYLES = ` + body { margin: 0; font-family: ${SANS}; color: ${SLATE[1000]}; } + #unlock { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; padding: 14px 20px; background: ${SLATE[1000]}; color: #fff; } + #unlock strong { font-size: 15px; font-weight: 600; margin-right: 4px; } + #unlock strong span { color: ${LIME[400]}; } + #unlock label { font-size: 13px; color: ${SLATE[300]}; } + #unlock input { flex: 1 1 280px; max-width: 420px; padding: 7px 10px; font: inherit; font-size: 14px; color: #fff; background: ${SLATE[900]}; border: 1px solid ${SLATE[700]}; border-radius: 4px; } + #unlock input:focus { outline: 2px solid ${LIME[500]}; outline-offset: 1px; } + #unlock button { padding: 7px 16px; font: inherit; font-size: 14px; font-weight: 600; color: ${SLATE[1000]}; background: ${LIME[500]}; border: 0; border-radius: 4px; cursor: pointer; } + #unlock button:hover { background: ${LIME[400]}; } + #error { display: none; margin: 20px; padding: 12px 14px; border-left: 3px solid ${RED[700]}; background: ${RED[100]}; color: ${RED[700]}; font-size: 14px; white-space: pre-wrap; } + #error[data-shown] { display: block; } +`; diff --git a/packages/agent-bff/test/docs/docs-routes.test.ts b/packages/agent-bff/test/docs/docs-routes.test.ts index 04cae102b9..faee9d052f 100644 --- a/packages/agent-bff/test/docs/docs-routes.test.ts +++ b/packages/agent-bff/test/docs/docs-routes.test.ts @@ -5,6 +5,7 @@ import path from 'path'; import request from 'supertest'; import createDocsRoutes, { DOCS_BUNDLE_PATH, DOCS_PATH } from '../../src/docs/docs-routes'; +import { REDOC_THEME } from '../../src/docs/docs-theme'; const DOCUMENT_PATH = '/agent/openapi.json'; @@ -56,6 +57,19 @@ describe('docs routes', () => { expect(response.text).not.toContain(' { + const response = await request(buildApp(true).callback()).get(DOCS_PATH); + + expect(response.text).not.toMatch(/https?:\/\/|\/\/fonts\./); + }); + + it('should carry the Forest theme, so the viewer is not stock Redoc', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_PATH); + + expect(response.text).toContain(JSON.stringify(REDOC_THEME.sidebar.activeTextColor)); + expect(response.text).toContain(REDOC_THEME.typography.fontFamily); + }); + it('should never be cached, since the page is the entry point to a credential prompt', async () => { const response = await request(buildApp(true).callback()).get(DOCS_PATH); From c9aa82211f38be2f6afc05e4df0a751af28c9eac Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 20 Aug 2026 16:15:25 +0200 Subject: [PATCH 05/12] refactor(agent-bff): drop "Admin" from the product name in the BFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/agent-bff/README.md | 2 +- packages/agent-bff/src/openapi/openapi-document.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index fdfda47b8f..124f3d2749 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -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 diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index ccb63074c6..1a1bd72869 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -330,7 +330,7 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): return new OpenApiGeneratorV31(registry.definitions).generateDocument({ openapi: OPENAPI_VERSION, info: { - title: 'Forest Admin BFF', + title: 'Forest BFF', version, license: { name: 'GPL-3.0', url: 'https://www.gnu.org/licenses/gpl-3.0.html' }, description: `${ From 755cdb6c0571c42c54a315e8114ef3931922cee9 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 20 Aug 2026 16:20:44 +0200 Subject: [PATCH 06/12] feat(agent-bff): give the docs page the product favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/agent-bff/src/docs/docs-page.ts | 3 ++- packages/agent-bff/src/docs/docs-theme.ts | 14 ++++++++++++++ packages/agent-bff/test/docs/docs-routes.test.ts | 6 ++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/agent-bff/src/docs/docs-page.ts b/packages/agent-bff/src/docs/docs-page.ts index b4f8aec634..4958f12458 100644 --- a/packages/agent-bff/src/docs/docs-page.ts +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -14,7 +14,7 @@ * form there is no default action to prevent and no submit to observe: without this script the button * does nothing at all. */ -import { PAGE_STYLES, REDOC_THEME } from './docs-theme'; +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 @@ -30,6 +30,7 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) Forest BFF API + diff --git a/packages/agent-bff/src/docs/docs-theme.ts b/packages/agent-bff/src/docs/docs-theme.ts index da53914581..224801bb51 100644 --- a/packages/agent-bff/src/docs/docs-theme.ts +++ b/packages/agent-bff/src/docs/docs-theme.ts @@ -80,6 +80,20 @@ export const REDOC_THEME = { schema: { typeNameColor: SLATE[700], typeTitleColor: LIME[700], requireLabelColor: RED[700] }, } as const; +/** + * The product logo, verbatim from the frontend's `public/img/logo.svg` minus its XML prolog — the + * mark itself, not a redrawing of it, so it cannot drift in geometry. The tree is negative space: + * the lime path covers everything the dark ground does not show through. Both colours are the logo's + * own, brighter and darker than any shade the interface palette carries. + * + * Inline rather than a file: this page must request nothing off-origin, and a favicon is a request + * like any other. It also needs no route of its own, and no bundle to exist. + */ +export const FAVICON_SVG = ` + + +`; + /** The shell around Redoc: the key prompt, and the error box it writes into. */ export const PAGE_STYLES = ` body { margin: 0; font-family: ${SANS}; color: ${SLATE[1000]}; } diff --git a/packages/agent-bff/test/docs/docs-routes.test.ts b/packages/agent-bff/test/docs/docs-routes.test.ts index faee9d052f..70290dc619 100644 --- a/packages/agent-bff/test/docs/docs-routes.test.ts +++ b/packages/agent-bff/test/docs/docs-routes.test.ts @@ -70,6 +70,12 @@ describe('docs routes', () => { expect(response.text).toContain(REDOC_THEME.typography.fontFamily); }); + it('should carry its favicon inline, since an icon file would be a request off this page', async () => { + const response = await request(buildApp(true).callback()).get(DOCS_PATH); + + expect(response.text).toContain('rel="icon" href="data:image/svg+xml,'); + }); + it('should never be cached, since the page is the entry point to a credential prompt', async () => { const response = await request(buildApp(true).callback()).get(DOCS_PATH); From 2344adaa767b1f4b51a5167a14f49ed826d40d5b Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 20 Aug 2026 17:29:35 +0200 Subject: [PATCH 07/12] fix(agent-bff): let the docs page apply only its current attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/agent-bff/src/docs/docs-page.ts | 16 ++ .../agent-bff/test/docs/docs-page.test.ts | 193 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 packages/agent-bff/test/docs/docs-page.test.ts diff --git a/packages/agent-bff/src/docs/docs-page.ts b/packages/agent-bff/src/docs/docs-page.ts index 4958f12458..012b4b174d 100644 --- a/packages/agent-bff/src/docs/docs-page.ts +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -52,6 +52,7 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) var input = document.getElementById('key'); var button = document.getElementById('load'); var errorBox = document.getElementById('error'); + var attempts = 0; function show(message) { errorBox.textContent = message; @@ -93,9 +94,20 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) } } + /** + * 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) { hide(); + var attempt = ++attempts; + var current = function () { + return attempt === attempts; + }; + fetch(DOCUMENT_PATH, { cache: 'no-store', headers: { 'X-Forest-Bff-Key': key }, @@ -114,6 +126,8 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) }); }) .then(function (result) { + if (!current()) return; + if (!result.ok) { show(describe(result.status, result.body)); @@ -123,6 +137,8 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) render(result.body); }) .catch(function (fetchError) { + if (!current()) return; + show('Could not reach ' + DOCUMENT_PATH + ': ' + fetchError); }); } diff --git a/packages/agent-bff/test/docs/docs-page.test.ts b/packages/agent-bff/test/docs/docs-page.test.ts new file mode 100644 index 0000000000..ee43d44382 --- /dev/null +++ b/packages/agent-bff/test/docs/docs-page.test.ts @@ -0,0 +1,193 @@ +import vm from 'vm'; + +import renderDocsPage from '../../src/docs/docs-page'; +import { REDOC_THEME } from '../../src/docs/docs-theme'; + +const DOCUMENT_PATH = '/agent/openapi.json'; +const BUNDLE_PATH = '/docs/redoc.standalone.js'; +const ELEMENT_IDS = ['unlock', 'key', 'load', 'error', 'redoc']; + +interface FakeElement { + style: Record; + value: string; + textContent: string; + attributes: Record; + listeners: Record void)[]>; + setAttribute(name: string, value: string): void; + removeAttribute(name: string): void; + addEventListener(type: string, handler: (event?: unknown) => void): void; +} + +interface PendingResponse { + resolve(response: { ok: boolean; status: number; body: unknown }): void; + reject(error: Error): void; +} + +function createElement(): FakeElement { + return { + style: {}, + value: '', + textContent: '', + attributes: {}, + listeners: {}, + setAttribute(name, value) { + this.attributes[name] = value; + }, + removeAttribute(name) { + delete this.attributes[name]; + }, + addEventListener(type, handler) { + this.listeners[type] = [...(this.listeners[type] ?? []), handler]; + }, + }; +} + +function extractInlineScript(html: string): string { + return html.split('')[0]; +} + +function runPage() { + const elements = new Map(ELEMENT_IDS.map(id => [id, createElement()])); + const pending: PendingResponse[] = []; + const redocInit = jest.fn(); + + const sandbox = { + JSON, + Promise, + document: { getElementById: (id: string) => elements.get(id) ?? null }, + Redoc: { init: redocInit }, + fetch: () => + new Promise((resolveFetch, rejectFetch) => { + pending.push({ + resolve({ ok, status, body }) { + resolveFetch({ ok, status, text: () => Promise.resolve(JSON.stringify(body)) }); + }, + reject: rejectFetch, + }); + }), + }; + + vm.createContext(sandbox); + vm.runInContext(extractInlineScript(renderDocsPage(DOCUMENT_PATH, BUNDLE_PATH)), sandbox); + + const errorBox = elements.get('error') as FakeElement; + + return { + redocInit, + pending, + errorShown: () => errorBox.attributes['data-shown'] !== undefined, + errorText: () => errorBox.textContent, + promptHidden: () => (elements.get('unlock') as FakeElement).style.display === 'none', + submit(key: string) { + const input = elements.get('key') as FakeElement; + input.value = key; + (elements.get('load') as FakeElement).listeners.click[0](); + }, + flush: () => + new Promise(resolve => { + process.nextTick(resolve); + }), + }; +} + +describe('docs page script', () => { + describe('when a submission is abandoned for another one', () => { + it('should ignore the abandoned error, since it did not come from the key on screen', async () => { + const page = runPage(); + + page.submit('mistyped-key'); + page.submit('good-key'); + + page.pending[1].resolve({ ok: true, status: 200, body: { openapi: '3.1.0' } }); + await page.flush(); + + page.pending[0].resolve({ + ok: false, + status: 401, + body: { error: { type: 'unauthorized', message: 'no' } }, + }); + await page.flush(); + + expect({ shown: page.errorShown(), renders: page.redocInit.mock.calls.length }).toEqual({ + shown: false, + renders: 1, + }); + }); + + it('should render the document of the last submission, whatever order the answers arrive in', async () => { + const page = runPage(); + + page.submit('first-key'); + page.submit('second-key'); + + page.pending[1].resolve({ ok: true, status: 200, body: { info: { title: 'second' } } }); + await page.flush(); + + page.pending[0].resolve({ ok: true, status: 200, body: { info: { title: 'first' } } }); + await page.flush(); + + expect(page.redocInit).toHaveBeenCalledTimes(1); + expect(page.redocInit.mock.calls[0][0]).toEqual({ info: { title: 'second' } }); + }); + + it('should ignore an abandoned network failure, since the current attempt may still succeed', async () => { + const page = runPage(); + + page.submit('first-key'); + page.submit('second-key'); + + page.pending[0].reject(new Error('connection reset')); + await page.flush(); + + expect(page.errorShown()).toBe(false); + + page.pending[1].resolve({ ok: true, status: 200, body: { openapi: '3.1.0' } }); + await page.flush(); + + expect(page.redocInit).toHaveBeenCalledTimes(1); + }); + }); + + describe('when a single submission answers', () => { + it('should hand the document to Redoc with the theme and an untrusted spec', async () => { + const page = runPage(); + + page.submit('good-key'); + page.pending[0].resolve({ ok: true, status: 200, body: { openapi: '3.1.0' } }); + await page.flush(); + + expect(page.redocInit).toHaveBeenCalledWith( + { openapi: '3.1.0' }, + { hideDownloadButton: true, untrustedSpec: true, theme: REDOC_THEME }, + expect.anything(), + ); + expect(page.promptHidden()).toBe(true); + }); + + it('should report the BFF error type rather than a generic failure', async () => { + const page = runPage(); + + page.submit('revoked-key'); + page.pending[0].resolve({ + ok: false, + status: 401, + body: { error: { type: 'unauthorized', message: 'Key revoked' } }, + }); + await page.flush(); + + expect(page.errorText()).toBe('The BFF answered 401 unauthorized: Key revoked'); + expect(page.redocInit).not.toHaveBeenCalled(); + }); + + it('should keep the prompt on screen when the key is empty, since nothing was sent', () => { + const page = runPage(); + + page.submit(' '); + + expect({ shown: page.errorShown(), requests: page.pending.length }).toEqual({ + shown: true, + requests: 0, + }); + }); + }); +}); From 9eea40d5dc642c6bd554b54be94a759afeb48171 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Thu, 20 Aug 2026 17:36:23 +0200 Subject: [PATCH 08/12] fix(agent-bff): treat an unparsable document as a failure, not a document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/agent-bff/src/docs/docs-page.ts | 14 +++++++------ .../agent-bff/test/docs/docs-page.test.ts | 21 ++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/agent-bff/src/docs/docs-page.ts b/packages/agent-bff/src/docs/docs-page.ts index 012b4b174d..b3e7139512 100644 --- a/packages/agent-bff/src/docs/docs-page.ts +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -114,15 +114,17 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) }) .then(function (response) { return response.text().then(function (text) { - var body; - try { - body = JSON.parse(text); + return { ok: response.ok, status: response.status, body: JSON.parse(text) }; } catch (parseError) { - body = { error: { type: 'unreadable_response', message: text.slice(0, 200) } }; + // 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) } }, + }; } - - return { ok: response.ok, status: response.status, body: body }; }); }) .then(function (result) { diff --git a/packages/agent-bff/test/docs/docs-page.test.ts b/packages/agent-bff/test/docs/docs-page.test.ts index ee43d44382..3a790458a4 100644 --- a/packages/agent-bff/test/docs/docs-page.test.ts +++ b/packages/agent-bff/test/docs/docs-page.test.ts @@ -20,6 +20,7 @@ interface FakeElement { interface PendingResponse { resolve(response: { ok: boolean; status: number; body: unknown }): void; + resolveText(response: { ok: boolean; status: number; text: string }): void; reject(error: Error): void; } @@ -62,6 +63,9 @@ function runPage() { resolve({ ok, status, body }) { resolveFetch({ ok, status, text: () => Promise.resolve(JSON.stringify(body)) }); }, + resolveText({ ok, status, text }) { + resolveFetch({ ok, status, text: () => Promise.resolve(text) }); + }, reject: rejectFetch, }); }), @@ -83,9 +87,11 @@ function runPage() { input.value = key; (elements.get('load') as FakeElement).listeners.click[0](); }, + // setImmediate, not nextTick: the fetch chain is three promise hops deep, and the nextTick queue + // runs BEFORE the microtask queue, so a single tick can resolve while continuations are pending. flush: () => new Promise(resolve => { - process.nextTick(resolve); + setImmediate(resolve); }), }; } @@ -179,6 +185,19 @@ describe('docs page script', () => { expect(page.redocInit).not.toHaveBeenCalled(); }); + it('should refuse an unparsable body, since a 200 that is not JSON is not a document', async () => { + const page = runPage(); + + page.submit('good-key'); + page.pending[0].resolveText({ ok: true, status: 200, text: 'gateway' }); + await page.flush(); + + expect(page.redocInit).not.toHaveBeenCalled(); + expect(page.errorText()).toBe( + 'The BFF answered 200 unreadable_response: gateway', + ); + }); + it('should keep the prompt on screen when the key is empty, since nothing was sent', () => { const page = runPage(); From 787fc3e176284b673f10303413641b61d8ead0a5 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 21 Aug 2026 11:35:32 +0200 Subject: [PATCH 09/12] feat(agent-bff): group the unfolded document by collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unfolded operations carried no tags, so a viewer had no structure to group by and rendered one flat list of everything. On a 16-collection schema that is 82 entries in a row; a real one runs into the hundreds, and the reader cannot find a collection in it. Each operation now carries its collection as its tag, and the document declares the tag list. Relation and action operations take their PARENT collection rather than the foreign one, so a group answers "what can I do with this collection", which is the question the reader arrives with. The tag list is declared rather than left to first appearance: it fixes the grouping order, and it hands a consumer the collection list without parsing paths for it. The generic document declares none — one operation per shape has nothing to group. `tag` is required on `OperationOptions` rather than optional, so the compiler pins every call site and a future operation cannot be added untagged by omission. Measured on a real 16-collection schema: 82 operations, 16 declared tags, no untagged operation, no undeclared tag, and `redocly lint` still clean. Two of the tests are those last invariants rather than examples, which is what stops the flat list from creeping back one operation at a time. Co-Authored-By: Claude Opus 5 (1M context) --- .../agent-bff/src/openapi/openapi-document.ts | 9 +++ .../agent-bff/src/openapi/unfolded-paths.ts | 14 +++++ .../test/openapi/openapi-unfolded.test.ts | 58 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index 1a1bd72869..73a7d94569 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -329,6 +329,15 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): return new OpenApiGeneratorV31(registry.definitions).generateDocument({ openapi: OPENAPI_VERSION, + // Declared rather than left to first appearance: this is what fixes the order a viewer groups by, + // and it gives a consumer the collection list without parsing paths for it. The generic document + // has one operation per shape and nothing to group. + tags: unfolding?.collections.map(collection => ({ + name: collection.name, + description: `Records, relations and actions of the ${JSON.stringify( + collection.name, + )} collection.`, + })), info: { title: 'Forest BFF', version, diff --git a/packages/agent-bff/src/openapi/unfolded-paths.ts b/packages/agent-bff/src/openapi/unfolded-paths.ts index bc709e7439..5e1a1a7f0d 100644 --- a/packages/agent-bff/src/openapi/unfolded-paths.ts +++ b/packages/agent-bff/src/openapi/unfolded-paths.ts @@ -415,6 +415,13 @@ function registerActionRequest( interface OperationOptions { path: string; operationId: string; + /** + * The collection the operation belongs to, verbatim. Untagged, an unfolded document renders as one + * flat list of every operation — hundreds of entries on a real schema — because a viewer has no + * other structure to group by. The relation and action operations take their PARENT collection, so + * a group answers "what can I do with this collection", which is how a reader arrives. + */ + tag: string; summary: string; description: string; request: ReferenceObject; @@ -429,6 +436,7 @@ function registerOperation(deps: Deps, options: OperationOptions): void { method: 'post', path: `${deps.prefix}/${options.path}`, operationId: options.operationId, + tags: [options.tag], summary: options.summary, description: options.description, security: deps.security, @@ -458,6 +466,7 @@ function registerCollectionOperations(deps: Deps, plan: CollectionPlan): void { registerOperation(deps, { path: `${segment(name)}/list`, operationId: `listRecords_${plan.key}`, + tag: name, summary: `List records of ${name}`, description: `Lists records of the ${quoted(name)} collection.`, request: plan.requests.list, @@ -469,6 +478,7 @@ function registerCollectionOperations(deps: Deps, plan: CollectionPlan): void { registerOperation(deps, { path: `${segment(name)}/count`, operationId: `countRecords_${plan.key}`, + tag: name, summary: `Count records of ${name}`, description: `Counts records of the ${quoted(name)} collection.`, request: plan.requests.count, @@ -497,6 +507,7 @@ function registerRelationOperations( registerOperation(deps, { path: `${segment(plan.collection.name)}/relations/${segment(relation.name)}/list`, operationId: `listRelatedRecords_${relationKey}`, + tag: plan.collection.name, summary: `List ${relation.name} of ${plan.collection.name}`, description: `Lists the ${quoted(foreign.collection.name)} records related to a ${quoted( plan.collection.name, @@ -510,6 +521,7 @@ function registerRelationOperations( registerOperation(deps, { path: `${segment(plan.collection.name)}/relations/${segment(relation.name)}/count`, operationId: `countRelatedRecords_${relationKey}`, + tag: plan.collection.name, summary: `Count ${relation.name} of ${plan.collection.name}`, description: `Counts the ${quoted( foreign.collection.name, @@ -534,6 +546,7 @@ function registerActionOperations(deps: Deps, plan: CollectionPlan, namer: Namer registerOperation(deps, { path: `${base}/form`, operationId: `getActionForm_${actionKey}`, + tag: plan.collection.name, summary: `Load the form of ${action.name} on ${plan.collection.name}`, description: `Loads the form of the custom action. ${identity} An unknown submitted field is skipped here, not rejected.`, request, @@ -545,6 +558,7 @@ function registerActionOperations(deps: Deps, plan: CollectionPlan, namer: Namer registerOperation(deps, { path: `${base}/execute`, operationId: `executeAction_${actionKey}`, + tag: plan.collection.name, summary: `Execute ${action.name} on ${plan.collection.name}`, description: `Executes the custom action. ${identity} A submitted field the loaded form does not carry is rejected with 400.`, request, diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index 290e50560f..2330ccd1a6 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -517,3 +517,61 @@ describe('an unfolded document with no action', () => { ); }); }); + +describe('the tags of an unfolded document', () => { + const tagsOf = (path: string) => operation(path).tags; + + it('should declare one tag per collection, in the order the schema exposes them', () => { + expect(document.tags).toEqual([ + { + name: 'My Coll', + description: 'Records, relations and actions of the "My Coll" collection.', + }, + { name: 'orders', description: 'Records, relations and actions of the "orders" collection.' }, + { + name: 'users.address', + description: 'Records, relations and actions of the "users.address" collection.', + }, + ]); + }); + + it('should tag records operations with their own collection', () => { + expect([tagsOf('My%20Coll/list'), tagsOf('My%20Coll/count')]).toEqual([ + ['My Coll'], + ['My Coll'], + ]); + }); + + it('should tag a relation with the parent collection, not the foreign one', () => { + expect(tagsOf('My%20Coll/relations/orders/list')).toEqual(['My Coll']); + }); + + it('should tag an action with the collection it belongs to', () => { + expect(tagsOf('My%20Coll/actions/Mark%20as%20paid%2Fdone/execute')).toEqual(['My Coll']); + }); + + it('should leave no operation untagged, since one would fall outside every group', () => { + const untagged = Object.entries(document.paths ?? {}) + .filter(([, item]) => ((item as { post: { tags?: string[] } }).post.tags ?? []).length === 0) + .map(([path]) => path); + + expect(untagged).toEqual([]); + }); + + it('should reference only declared tags, so a viewer groups nothing under an unknown name', () => { + const declared = new Set((document.tags ?? []).map(tag => tag.name)); + const used = new Set( + Object.values(document.paths ?? {}).flatMap( + item => (item as { post: { tags?: string[] } }).post.tags ?? [], + ), + ); + + expect([...used].filter(tag => !declared.has(tag))).toEqual([]); + }); +}); + +describe('the tags of a generic document', () => { + it('should carry none, since one operation per shape has nothing to group', () => { + expect(generateOpenApiDocument('9.9.9').tags).toBeUndefined(); + }); +}); From 7b9caca0b877f61e5e2c9bebb3855d3c88489ce5 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 21 Aug 2026 12:05:17 +0200 Subject: [PATCH 10/12] feat(agent-bff): give every operation a curl, node and ruby sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs page now decorates the fetched document with `x-codeSamples`, which Redoc renders as one tab per language in the right panel. In the page rather than in the document, deliberately. Three samples per operation weigh ~73 KB on a 16-collection schema and several hundred KB on a large one, which every consumer of `/agent/openapi.json` would pay for an extension only a viewer reads — where this costs one function whatever the operation count. It also lets a sample carry the REAL origin: the document declares `servers: [{ url: '/' }]`, so a sample built into it could only hold a placeholder host, while the page knows where it is served from and emits a command that runs as pasted. The generator reads the document rather than assuming the routes. The auth header comes from the security scheme the operation names — apiKey in header takes its name, http bearer becomes `Authorization: Bearer` — and the body carries exactly the properties the request schema makes required, flattening `allOf`. That yields `parentId` for a relation, `recordIds` for an action and an empty body for a list without those three families being written down anywhere. The timezone header is always emitted: `resolveTimezone` throws `missing_timezone` when the header, the body field and the deployment default are all absent, so a sample without it is a 400 — which is exactly what a hand-written snippet forgets. The key is never inlined. Each language reads it from the environment, so a copied sample cannot carry a credential into a shell history. Two guards the tests forced out. `withSamples` swallows its own failures: the decoration first sat inside `Redoc.init`'s try, so a generator bug would have surfaced as "Redoc could not render the document" and sent the reader looking in the wrong place; a shape the generator cannot walk now costs the snippets, never the page. And placeholder resolution is depth-bounded, because a filter is a condition tree and a schema can reference itself. Verified beyond "renders something": the 17 tests assert the exact curl, node and ruby sources, that no sample carries the key the reader typed, and that a self-referential schema still renders. Against the real 82-operation document, all 82 get three samples with no missing auth or timezone header, and every generated node and ruby sample parses (`ruby -c`, 82/82 each). Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent-bff/src/docs/docs-page.ts | 5 +- packages/agent-bff/src/docs/docs-samples.ts | 261 ++++++++++++++++++ .../agent-bff/test/docs/docs-page.test.ts | 221 +++++++++++++++ 3 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 packages/agent-bff/src/docs/docs-samples.ts diff --git a/packages/agent-bff/src/docs/docs-page.ts b/packages/agent-bff/src/docs/docs-page.ts index b3e7139512..82fb20e388 100644 --- a/packages/agent-bff/src/docs/docs-page.ts +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -14,6 +14,7 @@ * 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'; /** @@ -53,7 +54,7 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) 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', ''); @@ -87,7 +88,7 @@ export default function renderDocsPage(documentPath: string, bundlePath: string) unlock.style.display = 'none'; try { - Redoc.init(spec, REDOC_OPTIONS, document.getElementById('redoc')); + Redoc.init(withSamples(spec), REDOC_OPTIONS, document.getElementById('redoc')); } catch (initError) { unlock.style.display = ''; show('The Redoc viewer could not render the document: ' + initError); diff --git a/packages/agent-bff/src/docs/docs-samples.ts b/packages/agent-bff/src/docs/docs-samples.ts new file mode 100644 index 0000000000..2683410662 --- /dev/null +++ b/packages/agent-bff/src/docs/docs-samples.ts @@ -0,0 +1,261 @@ +import { BFF_KEY_HEADER } from '../api-key/api-key-middleware'; +import { TIMEZONE_HEADER } from '../timezone/timezone-middleware'; + +/** Neutral and always valid, where a local zone would only be right for whoever generated it. */ +const SAMPLE_TIMEZONE = 'UTC'; +const KEY_VARIABLE = 'BFF_KEY'; + +/** + * Browser source, injected into the page: it decorates the fetched document with `x-codeSamples`, + * which Redoc renders as one tab per language, then hands it to `Redoc.init`. + * + * In the page rather than in the document, deliberately. Three samples per operation weigh ~73 KB on + * a 16-collection schema and several hundred KB on a large one, which every consumer of + * `/agent/openapi.json` would pay for an extension only a viewer reads — where this costs one + * function whatever the operation count. It also lets a sample carry the REAL origin: the document + * declares `servers: [{ url: '/' }]`, so a sample built into it could only hold a placeholder host, + * while the page knows where it is served from and emits a command that runs as pasted. + * + * The key is never inlined: each language reads it from the environment, so a copied sample cannot + * carry a credential into a shell history or a paste. + * + * Everything else is read from the document rather than assumed: the auth header comes from the + * security scheme the operation names, and the body carries exactly the properties its request + * schema makes required — `parentId` for a relation, `recordIds` for an action, nothing at all for a + * list, whose body is optional. The one header that cannot be read off an operation is the timezone, + * a component-level parameter reference; resolving it would buy nothing, since omitting it is a 400 + * (`resolveTimezone` throws `missing_timezone` when header, body field and deployment default are + * all absent) and that is precisely what a hand-written sample forgets. + */ +const SAMPLES_SCRIPT = ` + var KEY_HEADER = ${JSON.stringify(BFF_KEY_HEADER)}; + var TIMEZONE_HEADER = ${JSON.stringify(TIMEZONE_HEADER)}; + var SAMPLE_TIMEZONE = ${JSON.stringify(SAMPLE_TIMEZONE)}; + var KEY_VARIABLE = ${JSON.stringify(KEY_VARIABLE)}; + + function schemaOf(spec, node) { + if (!node) return {}; + + if (node.$ref) { + var schemas = (spec.components || {}).schemas || {}; + + return schemaOf(spec, schemas[node.$ref.split('/').pop()]); + } + + return node; + } + + // A relation request is its foreign collection request plus a parent id, expressed as allOf. + function flatten(spec, node) { + var schema = schemaOf(spec, node); + + if (!schema.allOf) return schema; + + var merged = { properties: {}, required: [] }; + + schema.allOf.forEach(function (part) { + var flat = flatten(spec, part); + + Object.keys(flat.properties || {}).forEach(function (name) { + merged.properties[name] = flat.properties[name]; + }); + merged.required = merged.required.concat(flat.required || []); + }); + + return merged; + } + + // Depth-bounded: a filter is a condition TREE, so a schema can reference itself, and a + // future required field of that shape would otherwise recurse until the stack gives out. + function placeholder(spec, name, node, depth) { + if (depth > 6) return '<' + name + '>'; + + var schema = schemaOf(spec, node); + var alternatives = schema.anyOf || schema.oneOf; + + if (alternatives && alternatives.length) { + return placeholder(spec, name, alternatives[0], depth + 1); + } + + if (schema.enum && schema.enum.length) return schema.enum[0]; + if (schema.type === 'array') return [placeholder(spec, name, schema.items, depth + 1)]; + if (schema.type === 'number' || schema.type === 'integer') return 0; + if (schema.type === 'boolean') return true; + if (schema.type === 'object') return {}; + + return '<' + name + '>'; + } + + function exampleBody(spec, operation) { + var content = ((operation.requestBody || {}).content || {})['application/json']; + + if (!content) return undefined; + + var schema = flatten(spec, content.schema); + var body = {}; + + (schema.required || []).forEach(function (name) { + body[name] = placeholder(spec, name, (schema.properties || {})[name], 0); + }); + + return body; + } + + // The scheme the operation names, so a document that renames or re-types it stays right. + function authHeader(spec, operation) { + var schemes = (spec.components || {}).securitySchemes || {}; + var requirements = operation.security || spec.security || []; + var header = null; + + requirements.forEach(function (requirement) { + Object.keys(requirement).forEach(function (name) { + if (header) return; + + var scheme = schemes[name] || {}; + + if (scheme.type === 'apiKey' && scheme.in === 'header') { + header = { name: scheme.name, prefix: '', secret: true }; + } else if (scheme.type === 'http' && scheme.scheme === 'bearer') { + header = { name: 'Authorization', prefix: 'Bearer ', secret: true }; + } + }); + }); + + return header || { name: KEY_HEADER, prefix: '', secret: true }; + } + + function headersOf(spec, operation, body) { + var headers = [authHeader(spec, operation)]; + + headers.push({ name: TIMEZONE_HEADER, value: SAMPLE_TIMEZONE }); + + if (body !== undefined) headers.push({ name: 'Content-Type', value: 'application/json' }); + + return headers; + } + + function curlSample(url, method, headers, body) { + var lines = ["curl -X " + method + " '" + url + "'"]; + + headers.forEach(function (header) { + var value = header.secret ? header.prefix + '$' + KEY_VARIABLE : header.value; + + lines.push(" -H '" + header.name + ": " + value + "'"); + }); + + if (body !== undefined) lines.push(" -d '" + JSON.stringify(body) + "'"); + + return lines.join(' \\\\\\n'); + } + + function nodeSample(url, method, headers, body) { + var lines = [ + "const response = await fetch('" + url + "', {", + " method: '" + method + "',", + ' headers: {', + ]; + + headers.forEach(function (header) { + var value = header.secret + ? (header.prefix ? "'" + header.prefix + "' + " : '') + + 'process.env.' + + KEY_VARIABLE + : "'" + header.value + "'"; + + lines.push(" '" + header.name + "': " + value + ','); + }); + + lines.push(' },'); + + if (body !== undefined) { + lines.push(' body: JSON.stringify(' + JSON.stringify(body) + '),'); + } + + lines.push('});'); + lines.push(''); + lines.push( + "if (!response.ok) throw new Error(response.status + ' ' + (await response.text()));", + ); + lines.push(''); + lines.push('console.log(await response.json());'); + + return lines.join('\\n'); + } + + function rubySample(url, method, headers, body) { + var verb = method.charAt(0) + method.slice(1).toLowerCase(); + var lines = [ + "require 'json'", + "require 'net/http'", + '', + "uri = URI('" + url + "')", + 'request = Net::HTTP::' + verb + '.new(uri)', + ]; + + headers.forEach(function (header) { + var read = "ENV.fetch('" + KEY_VARIABLE + "')"; + var value = header.secret + ? (header.prefix ? '"' + header.prefix + '#{' + read + '}"' : read) + : "'" + header.value + "'"; + + lines.push("request['" + header.name + "'] = " + value); + }); + + if (body !== undefined) { + lines.push('request.body = JSON.generate(' + JSON.stringify(body) + ')'); + } + + lines.push(''); + lines.push( + "response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|", + ); + lines.push(' http.request(request)'); + lines.push('end'); + lines.push(''); + lines.push('puts response.body'); + + return lines.join('\\n'); + } + + function decorateWithSamples(spec, origin) { + var paths = spec.paths || {}; + + Object.keys(paths).forEach(function (path) { + var item = paths[path] || {}; + + Object.keys(item).forEach(function (method) { + var operation = item[method]; + + if (!operation || typeof operation !== 'object' || !operation.responses) return; + + var body = exampleBody(spec, operation); + var headers = headersOf(spec, operation, body); + var url = origin + path; + var verb = method.toUpperCase(); + + operation['x-codeSamples'] = [ + { lang: 'cURL', source: curlSample(url, verb, headers, body) }, + { lang: 'JavaScript', source: nodeSample(url, verb, headers, body) }, + { lang: 'Ruby', source: rubySample(url, verb, headers, body) }, + ]; + }); + }); + + return spec; + } + + /** + * Samples are a convenience; the document is the point. A shape the generator cannot walk + * costs the reader its snippets, never the page — and reporting it as a Redoc render failure + * would send them looking in the wrong place. + */ + function withSamples(spec) { + try { + return decorateWithSamples(spec, window.location.origin); + } catch (samplesError) { + return spec; + } + } +`; + +export default SAMPLES_SCRIPT; diff --git a/packages/agent-bff/test/docs/docs-page.test.ts b/packages/agent-bff/test/docs/docs-page.test.ts index 3a790458a4..77bd44ae73 100644 --- a/packages/agent-bff/test/docs/docs-page.test.ts +++ b/packages/agent-bff/test/docs/docs-page.test.ts @@ -5,6 +5,7 @@ import { REDOC_THEME } from '../../src/docs/docs-theme'; const DOCUMENT_PATH = '/agent/openapi.json'; const BUNDLE_PATH = '/docs/redoc.standalone.js'; +const ORIGIN = 'https://bff.example.com'; const ELEMENT_IDS = ['unlock', 'key', 'load', 'error', 'redoc']; interface FakeElement { @@ -55,6 +56,9 @@ function runPage() { const sandbox = { JSON, Promise, + Object, + Error, + window: { location: { origin: ORIGIN } }, document: { getElementById: (id: string) => elements.get(id) ?? null }, Redoc: { init: redocInit }, fetch: () => @@ -96,6 +100,74 @@ function runPage() { }; } +const API_KEY_SPEC = { + components: { + securitySchemes: { + bffApiKey: { type: 'apiKey', in: 'header', name: 'X-Forest-Bff-Key' }, + bffSession: { type: 'http', scheme: 'bearer' }, + }, + schemas: { + ListRequest_orders: { type: 'object', properties: { page: { type: 'object' } } }, + RelationListRequest: { + allOf: [ + { $ref: '#/components/schemas/ListRequest_orders' }, + { + type: 'object', + properties: { parentId: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + required: ['parentId'], + }, + ], + }, + ActionRequest: { + type: 'object', + properties: { + recordIds: { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + values: { type: 'object' }, + }, + required: ['recordIds'], + }, + }, + }, + paths: { + '/agent/v1/My%20Coll/list': { + post: { + security: [{ bffApiKey: [] }], + responses: { 200: {} }, + requestBody: { + required: false, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/ListRequest_orders' } }, + }, + }, + }, + }, + '/agent/v1/My%20Coll/relations/orders/list': { + post: { + security: [{ bffApiKey: [] }], + responses: { 200: {} }, + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/RelationListRequest' } }, + }, + }, + }, + }, + '/agent/v1/My%20Coll/actions/Mark%2Fdone/execute': { + post: { + security: [{ bffSession: [] }], + responses: { 200: {} }, + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/ActionRequest' } }, + }, + }, + }, + }, + }, +}; + describe('docs page script', () => { describe('when a submission is abandoned for another one', () => { it('should ignore the abandoned error, since it did not come from the key on screen', async () => { @@ -210,3 +282,152 @@ describe('docs page script', () => { }); }); }); + +describe('the code samples the docs page injects', () => { + async function render(spec: unknown, key = 'the-secret-key') { + const page = runPage(); + + page.submit(key); + page.pending[0].resolve({ ok: true, status: 200, body: spec }); + await page.flush(); + + const rendered = page.redocInit.mock.calls[0]?.[0] as { + paths: Record; + }; + + return { + rendered, + samplesOf: (path: string) => rendered.paths[path].post['x-codeSamples'] ?? [], + sourceOf: (path: string, lang: string) => + (rendered.paths[path].post['x-codeSamples'] ?? []).find(sample => sample.lang === lang) + ?.source ?? '', + allSources: () => + Object.values(rendered.paths).flatMap(item => + (item.post['x-codeSamples'] ?? []).map(sample => sample.source), + ), + }; + } + + const LIST = '/agent/v1/My%20Coll/list'; + const RELATION = '/agent/v1/My%20Coll/relations/orders/list'; + const ACTION = '/agent/v1/My%20Coll/actions/Mark%2Fdone/execute'; + + it('should offer the three languages on every operation', async () => { + const page = await render(API_KEY_SPEC); + + [LIST, RELATION, ACTION].forEach(path => { + expect(page.samplesOf(path).map(sample => sample.lang)).toEqual([ + 'cURL', + 'JavaScript', + 'Ruby', + ]); + }); + }); + + it('should build a curl command on the real origin, keeping the path encoded as served', async () => { + const page = await render(API_KEY_SPEC); + + expect(page.sourceOf(RELATION, 'cURL')).toBe( + [ + `curl -X POST '${ORIGIN}/agent/v1/My%20Coll/relations/orders/list' \\`, + " -H 'X-Forest-Bff-Key: $BFF_KEY' \\", + " -H 'X-Forest-Timezone: UTC' \\", + " -H 'Content-Type: application/json' \\", + ` -d '{"parentId":""}'`, + ].join('\n'), + ); + }); + + it('should send only what the request schema requires, flattening the relation allOf', async () => { + const page = await render(API_KEY_SPEC); + + expect(page.sourceOf(LIST, 'cURL')).toContain(`-d '{}'`); + expect(page.sourceOf(RELATION, 'cURL')).toContain(`-d '{"parentId":""}'`); + expect(page.sourceOf(ACTION, 'cURL')).toContain(`-d '{"recordIds":[""]}'`); + }); + + it('should carry the timezone header, which the BFF answers 400 without', async () => { + const page = await render(API_KEY_SPEC); + + page.allSources().forEach(source => expect(source).toContain('X-Forest-Timezone')); + }); + + it('should take the auth header from the scheme the operation names', async () => { + const page = await render(API_KEY_SPEC); + + expect(page.sourceOf(ACTION, 'cURL')).toContain("-H 'Authorization: Bearer $BFF_KEY'"); + expect(page.sourceOf(ACTION, 'cURL')).not.toContain('X-Forest-Bff-Key'); + }); + + it('should read the key from the environment in node, not inline it', async () => { + const page = await render(API_KEY_SPEC); + + expect(page.sourceOf(RELATION, 'JavaScript')).toBe( + [ + `const response = await fetch('${ORIGIN}/agent/v1/My%20Coll/relations/orders/list', {`, + " method: 'POST',", + ' headers: {', + " 'X-Forest-Bff-Key': process.env.BFF_KEY,", + " 'X-Forest-Timezone': 'UTC',", + " 'Content-Type': 'application/json',", + ' },', + ' body: JSON.stringify({"parentId":""}),', + '});', + '', + "if (!response.ok) throw new Error(response.status + ' ' + (await response.text()));", + '', + 'console.log(await response.json());', + ].join('\n'), + ); + }); + + it('should read the key from the environment in ruby, interpolating a bearer prefix', async () => { + const page = await render(API_KEY_SPEC); + + expect(page.sourceOf(RELATION, 'Ruby')).toContain( + "request['X-Forest-Bff-Key'] = ENV.fetch('BFF_KEY')", + ); + expect(page.sourceOf(ACTION, 'Ruby')).toContain( + `request['Authorization'] = "Bearer #{ENV.fetch('BFF_KEY')}"`, + ); + expect(page.sourceOf(RELATION, 'Ruby')).toContain('request = Net::HTTP::Post.new(uri)'); + }); + + it('should never carry the key the reader typed, whatever the language', async () => { + const page = await render(API_KEY_SPEC, 'fbff_deadbeef_cafe'); + + page.allSources().forEach(source => expect(source).not.toContain('fbff_deadbeef_cafe')); + }); + + it('should still render a document whose schema references itself', async () => { + const page = await render({ + components: { + schemas: { + Loop: { + type: 'object', + properties: { self: { $ref: '#/components/schemas/Loop' } }, + required: ['self'], + }, + }, + }, + paths: { + '/agent/v1/loop/list': { + post: { + responses: { 200: {} }, + requestBody: { + content: { 'application/json': { schema: { $ref: '#/components/schemas/Loop' } } }, + }, + }, + }, + }, + }); + + expect(page.rendered.paths['/agent/v1/loop/list'].post['x-codeSamples']).toHaveLength(3); + }); + + it('should leave a document with no path untouched rather than fail to render', async () => { + const page = await render({ openapi: '3.1.0' }); + + expect(page.rendered).toEqual({ openapi: '3.1.0' }); + }); +}); From a21b213bc99b38f90f4f0b0f17edd0ca9b4ae9a8 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 21 Aug 2026 12:17:33 +0200 Subject: [PATCH 11/12] fix(agent-bff): stop the samples from targeting a literal path template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sample URLs were `origin + path`, so on the GENERIC document — every path a template — all three languages pointed at a literal `/agent/v1/{collection}/list`. That document is reachable whenever the deployment cannot unfold: no AGENT_URL, or no read-model configuration. Each path parameter the operation declares now becomes the same `` placeholder the bodies already use, resolving a `$ref`'d parameter too. One notation across a snippet reads as "replace this", where `{collection}` could be mistaken for syntax the API expects. Deliberately NOT a "usable sample value": the generic document is served precisely because the deployment cannot enumerate its collections, so no real name exists to substitute, and an invented one would read as runnable and answer 404. The unfolded document is untouched by this — its segments are already the real names, URL-encoded — which a test pins. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent-bff/src/docs/docs-samples.ts | 28 +++++++++++++- .../agent-bff/test/docs/docs-page.test.ts | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/agent-bff/src/docs/docs-samples.ts b/packages/agent-bff/src/docs/docs-samples.ts index 2683410662..c3dcebc788 100644 --- a/packages/agent-bff/src/docs/docs-samples.ts +++ b/packages/agent-bff/src/docs/docs-samples.ts @@ -124,6 +124,32 @@ const SAMPLES_SCRIPT = ` return header || { name: KEY_HEADER, prefix: '', secret: true }; } + /** + * The unfolded document has no path parameter left — its segments are the real names, already + * URL-encoded — but the generic one is all templates, and a sample cannot be made to invoke + * those: the generic document is served precisely BECAUSE the deployment cannot enumerate its + * collections, so there is no real name to substitute. Inventing one would read as runnable + * and answer 404. Each template becomes the same \`\` placeholder the bodies use, so one + * notation across a snippet means "replace this" and none of it looks like API syntax. + */ + function samplePath(spec, item, operation, path) { + var parameters = (item.parameters || []).concat(operation.parameters || []); + var components = (spec.components || {}).parameters || {}; + var sampled = path; + + parameters.forEach(function (parameter) { + var declared = parameter && parameter.$ref + ? components[parameter.$ref.split('/').pop()] || {} + : parameter || {}; + + if (declared.in !== 'path' || !declared.name) return; + + sampled = sampled.split('{' + declared.name + '}').join('<' + declared.name + '>'); + }); + + return sampled; + } + function headersOf(spec, operation, body) { var headers = [authHeader(spec, operation)]; @@ -230,7 +256,7 @@ const SAMPLES_SCRIPT = ` var body = exampleBody(spec, operation); var headers = headersOf(spec, operation, body); - var url = origin + path; + var url = origin + samplePath(spec, item, operation, path); var verb = method.toUpperCase(); operation['x-codeSamples'] = [ diff --git a/packages/agent-bff/test/docs/docs-page.test.ts b/packages/agent-bff/test/docs/docs-page.test.ts index 77bd44ae73..a018128ee8 100644 --- a/packages/agent-bff/test/docs/docs-page.test.ts +++ b/packages/agent-bff/test/docs/docs-page.test.ts @@ -425,6 +425,43 @@ describe('the code samples the docs page injects', () => { expect(page.rendered.paths['/agent/v1/loop/list'].post['x-codeSamples']).toHaveLength(3); }); + it('should replace a path template with the placeholder notation the bodies use', async () => { + const page = await render({ + components: { + parameters: { + Collection: { + name: 'collection', + in: 'path', + schema: { type: 'string' }, + }, + }, + }, + paths: { + '/agent/v1/{collection}/actions/{action}/execute': { + post: { + responses: { 200: {} }, + parameters: [ + { $ref: '#/components/parameters/Collection' }, + { name: 'action', in: 'path', schema: { type: 'string' } }, + { name: 'X-Forest-Timezone', in: 'header', schema: { type: 'string' } }, + ], + }, + }, + }, + }); + + const curl = page.sourceOf('/agent/v1/{collection}/actions/{action}/execute', 'cURL'); + + expect(curl).toContain(`'${ORIGIN}/agent/v1//actions//execute'`); + expect(curl).not.toContain('{collection}'); + }); + + it('should leave an unfolded path alone, since its segments are already the real names', async () => { + const page = await render(API_KEY_SPEC); + + expect(page.sourceOf(LIST, 'cURL')).toContain(`'${ORIGIN}/agent/v1/My%20Coll/list'`); + }); + it('should leave a document with no path untouched rather than fail to render', async () => { const page = await render({ openapi: '3.1.0' }); From 8b02367e74e147e9aa7bef18beb2df2ea9bf2659 Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Fri, 21 Aug 2026 15:25:02 +0200 Subject: [PATCH 12/12] fix(agent-bff): quote sample values for the language that will run them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the generated samples, both in how values were interpolated. The curl secret header was single-quoted, so the shell never expanded it and the request carried the literal `$BFF_KEY` — a sample that looks runnable and earns a 401. It is double-quoted now, with the fixed text around the variable escaped for that context; every other header keeps single quotes, where expansion would be wrong. And an apostrophe in a name broke all three languages. `encodeURIComponent` does NOT encode `'`, so a collection called `John's orders` reaches the samples with its apostrophe intact, and a field name or enum value can carry one into a body. Interpolation now goes through the quoting of the target language: POSIX close-reopen (`'\''`) for shell, `JSON.stringify` for every JavaScript literal, backslash escaping for Ruby single quotes, and `#{` neutralised in the Ruby body since a JSON literal is double-quoted and Ruby interpolates there. Verified against real parsers rather than by eye: 164 samples per language — the 82-operation document plus a copy whose collection and field names carry apostrophes — all pass `bash -n`, JavaScript parsing and `ruby -c`. Running one through a stub `curl` shows the key expanded to its real value and the URL arriving as a single argument, apostrophe included. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent-bff/src/docs/docs-samples.ts | 62 +++++++++++---- .../agent-bff/test/docs/docs-page.test.ts | 75 +++++++++++++++++-- 2 files changed, 117 insertions(+), 20 deletions(-) diff --git a/packages/agent-bff/src/docs/docs-samples.ts b/packages/agent-bff/src/docs/docs-samples.ts index c3dcebc788..937797741d 100644 --- a/packages/agent-bff/src/docs/docs-samples.ts +++ b/packages/agent-bff/src/docs/docs-samples.ts @@ -160,35 +160,71 @@ const SAMPLES_SCRIPT = ` return headers; } + /** + * A collection or action name reaches these samples with its apostrophes intact: + * \`encodeURIComponent\` leaves \`'\` alone, so a collection called \`John's orders\` is a path + * segment carrying one, and a field name or enum value can carry one into a body. Every + * interpolation therefore goes through the quoting of its target language. + */ + function shellQuoted(value) { + return "'" + String(value).split("'").join("'\\\\''") + "'"; + } + + // For a value that must EXPAND: single quotes would send the literal '$BFF_KEY' and earn a + // 401. Only fixed text is ever placed here, but it is escaped for the context regardless. + function shellExpanding(text) { + return String(text).replace(/([\\\\"\`$])/g, '\\\\$1'); + } + + function rubyQuoted(value) { + return "'" + String(value).replace(/\\\\/g, '\\\\\\\\').split("'").join("\\\\'") + "'"; + } + + // Ruby interpolates #{...} inside double quotes, and a JSON literal is double-quoted. + function rubySafeJson(text) { + return String(text).split('#{').join('\\\\#{'); + } + function curlSample(url, method, headers, body) { - var lines = ["curl -X " + method + " '" + url + "'"]; + var lines = ['curl -X ' + method + ' ' + shellQuoted(url)]; headers.forEach(function (header) { - var value = header.secret ? header.prefix + '$' + KEY_VARIABLE : header.value; + if (header.secret) { + var expanded = + shellExpanding(header.name) + + ': ' + + shellExpanding(header.prefix) + + '$' + + KEY_VARIABLE; + + lines.push(' -H "' + expanded + '"'); + + return; + } - lines.push(" -H '" + header.name + ": " + value + "'"); + lines.push(' -H ' + shellQuoted(header.name + ': ' + header.value)); }); - if (body !== undefined) lines.push(" -d '" + JSON.stringify(body) + "'"); + if (body !== undefined) lines.push(' -d ' + shellQuoted(JSON.stringify(body))); return lines.join(' \\\\\\n'); } function nodeSample(url, method, headers, body) { var lines = [ - "const response = await fetch('" + url + "', {", - " method: '" + method + "',", + 'const response = await fetch(' + JSON.stringify(url) + ', {', + ' method: ' + JSON.stringify(method) + ',', ' headers: {', ]; headers.forEach(function (header) { var value = header.secret - ? (header.prefix ? "'" + header.prefix + "' + " : '') + + ? (header.prefix ? JSON.stringify(header.prefix) + ' + ' : '') + 'process.env.' + KEY_VARIABLE - : "'" + header.value + "'"; + : JSON.stringify(header.value); - lines.push(" '" + header.name + "': " + value + ','); + lines.push(' ' + JSON.stringify(header.name) + ': ' + value + ','); }); lines.push(' },'); @@ -214,7 +250,7 @@ const SAMPLES_SCRIPT = ` "require 'json'", "require 'net/http'", '', - "uri = URI('" + url + "')", + 'uri = URI(' + rubyQuoted(url) + ')', 'request = Net::HTTP::' + verb + '.new(uri)', ]; @@ -222,13 +258,13 @@ const SAMPLES_SCRIPT = ` var read = "ENV.fetch('" + KEY_VARIABLE + "')"; var value = header.secret ? (header.prefix ? '"' + header.prefix + '#{' + read + '}"' : read) - : "'" + header.value + "'"; + : rubyQuoted(header.value); - lines.push("request['" + header.name + "'] = " + value); + lines.push('request[' + rubyQuoted(header.name) + '] = ' + value); }); if (body !== undefined) { - lines.push('request.body = JSON.generate(' + JSON.stringify(body) + ')'); + lines.push('request.body = JSON.generate(' + rubySafeJson(JSON.stringify(body)) + ')'); } lines.push(''); diff --git a/packages/agent-bff/test/docs/docs-page.test.ts b/packages/agent-bff/test/docs/docs-page.test.ts index a018128ee8..3840995451 100644 --- a/packages/agent-bff/test/docs/docs-page.test.ts +++ b/packages/agent-bff/test/docs/docs-page.test.ts @@ -330,7 +330,7 @@ describe('the code samples the docs page injects', () => { expect(page.sourceOf(RELATION, 'cURL')).toBe( [ `curl -X POST '${ORIGIN}/agent/v1/My%20Coll/relations/orders/list' \\`, - " -H 'X-Forest-Bff-Key: $BFF_KEY' \\", + ' -H "X-Forest-Bff-Key: $BFF_KEY" \\', " -H 'X-Forest-Timezone: UTC' \\", " -H 'Content-Type: application/json' \\", ` -d '{"parentId":""}'`, @@ -338,6 +338,15 @@ describe('the code samples the docs page injects', () => { ); }); + it('should double-quote the secret header, since single quotes stop the shell expanding it', async () => { + const page = await render(API_KEY_SPEC); + const curl = page.sourceOf(RELATION, 'cURL'); + + expect(curl).toContain(' -H "X-Forest-Bff-Key: $BFF_KEY"'); + expect(curl).not.toContain("'X-Forest-Bff-Key: $BFF_KEY'"); + expect(curl).toContain(" -H 'X-Forest-Timezone: UTC'"); + }); + it('should send only what the request schema requires, flattening the relation allOf', async () => { const page = await render(API_KEY_SPEC); @@ -355,7 +364,7 @@ describe('the code samples the docs page injects', () => { it('should take the auth header from the scheme the operation names', async () => { const page = await render(API_KEY_SPEC); - expect(page.sourceOf(ACTION, 'cURL')).toContain("-H 'Authorization: Bearer $BFF_KEY'"); + expect(page.sourceOf(ACTION, 'cURL')).toContain('-H "Authorization: Bearer $BFF_KEY"'); expect(page.sourceOf(ACTION, 'cURL')).not.toContain('X-Forest-Bff-Key'); }); @@ -364,12 +373,12 @@ describe('the code samples the docs page injects', () => { expect(page.sourceOf(RELATION, 'JavaScript')).toBe( [ - `const response = await fetch('${ORIGIN}/agent/v1/My%20Coll/relations/orders/list', {`, - " method: 'POST',", + `const response = await fetch("${ORIGIN}/agent/v1/My%20Coll/relations/orders/list", {`, + ' method: "POST",', ' headers: {', - " 'X-Forest-Bff-Key': process.env.BFF_KEY,", - " 'X-Forest-Timezone': 'UTC',", - " 'Content-Type': 'application/json',", + ' "X-Forest-Bff-Key": process.env.BFF_KEY,', + ' "X-Forest-Timezone": "UTC",', + ' "Content-Type": "application/json",', ' },', ' body: JSON.stringify({"parentId":""}),', '});', @@ -462,6 +471,58 @@ describe('the code samples the docs page injects', () => { expect(page.sourceOf(LIST, 'cURL')).toContain(`'${ORIGIN}/agent/v1/My%20Coll/list'`); }); + describe('when a name carries an apostrophe, which URL encoding leaves alone', () => { + const QUOTED_SPEC = { + components: { + schemas: { + Quoted: { + type: 'object', + properties: { "it's": { type: 'string' } }, + required: ["it's"], + }, + }, + }, + paths: { + "/agent/v1/John's%20orders/list": { + post: { + responses: { 200: {} }, + requestBody: { + content: { 'application/json': { schema: { $ref: '#/components/schemas/Quoted' } } }, + }, + }, + }, + }, + }; + + const QUOTED_PATH = "/agent/v1/John's%20orders/list"; + + it('should close and reopen the shell quoting rather than break the command', async () => { + const page = await render(QUOTED_SPEC); + const curl = page.sourceOf(QUOTED_PATH, 'cURL'); + + expect(curl.split('\n')[0]).toBe( + `${String.raw`curl -X POST '${ORIGIN}/agent/v1/John'\''s%20orders/list' `}\\`, + ); + expect(curl).toContain(String.raw`-d '{"it'\''s":""}'`); + }); + + it('should escape it in the ruby single-quoted URI', async () => { + const page = await render(QUOTED_SPEC); + + expect(page.sourceOf(QUOTED_PATH, 'Ruby')).toContain( + String.raw`uri = URI('${ORIGIN}/agent/v1/John\'s%20orders/list')`, + ); + }); + + it('should leave it alone in node, where the literals are double-quoted', async () => { + const page = await render(QUOTED_SPEC); + + expect(page.sourceOf(QUOTED_PATH, 'JavaScript')).toContain( + `await fetch("${ORIGIN}/agent/v1/John's%20orders/list", {`, + ); + }); + }); + it('should leave a document with no path untouched rather than fail to render', async () => { const page = await render({ openapi: '3.1.0' });