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/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..a8caffa19e 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,14 @@ 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. 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 new file mode 100644 index 0000000000..82fb20e388 --- /dev/null +++ b/packages/agent-bff/src/docs/docs-page.ts @@ -0,0 +1,166 @@ +/** + * 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. + * + * 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. + */ +import SAMPLES_SCRIPT from './docs-samples'; +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 + * 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 ` + + + + + + Forest BFF API + + + + +
+ Forest. + + + +
+
+
+ + + + +`; +} 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..d2d87998ff --- /dev/null +++ b/packages/agent-bff/src/docs/docs-routes.ts @@ -0,0 +1,106 @@ +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 lookup, as a seam: an install that shipped without the asset is a real state to serve. */ + resolveBundlePath?: () => string | undefined; +} + +/** + * 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 { + /* 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; + } +} + +/** + * 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, + resolveBundlePath = resolveBundle, +}: DocsRoutesOptions): Middleware { + const bundle = enabled ? resolveBundlePath() : 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. 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'; + 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/src/docs/docs-samples.ts b/packages/agent-bff/src/docs/docs-samples.ts new file mode 100644 index 0000000000..937797741d --- /dev/null +++ b/packages/agent-bff/src/docs/docs-samples.ts @@ -0,0 +1,323 @@ +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 }; + } + + /** + * 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)]; + + headers.push({ name: TIMEZONE_HEADER, value: SAMPLE_TIMEZONE }); + + if (body !== undefined) headers.push({ name: 'Content-Type', value: 'application/json' }); + + 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 + ' ' + shellQuoted(url)]; + + headers.forEach(function (header) { + if (header.secret) { + var expanded = + shellExpanding(header.name) + + ': ' + + shellExpanding(header.prefix) + + '$' + + KEY_VARIABLE; + + lines.push(' -H "' + expanded + '"'); + + return; + } + + lines.push(' -H ' + shellQuoted(header.name + ': ' + header.value)); + }); + + 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(' + JSON.stringify(url) + ', {', + ' method: ' + JSON.stringify(method) + ',', + ' headers: {', + ]; + + headers.forEach(function (header) { + var value = header.secret + ? (header.prefix ? JSON.stringify(header.prefix) + ' + ' : '') + + 'process.env.' + + KEY_VARIABLE + : JSON.stringify(header.value); + + lines.push(' ' + JSON.stringify(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(' + rubyQuoted(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) + : rubyQuoted(header.value); + + lines.push('request[' + rubyQuoted(header.name) + '] = ' + value); + }); + + if (body !== undefined) { + lines.push('request.body = JSON.generate(' + rubySafeJson(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 + samplePath(spec, item, operation, 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/src/docs/docs-theme.ts b/packages/agent-bff/src/docs/docs-theme.ts new file mode 100644 index 0000000000..224801bb51 --- /dev/null +++ b/packages/agent-bff/src/docs/docs-theme.ts @@ -0,0 +1,110 @@ +/** + * 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 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]}; } + #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/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index ccb63074c6..73a7d94569 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -329,8 +329,17 @@ 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 Admin BFF', + title: 'Forest BFF', version, license: { name: 'GPL-3.0', url: 'https://www.gnu.org/licenses/gpl-3.0.html' }, description: `${ 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/cli-core.test.ts b/packages/agent-bff/test/cli-core.test.ts index e147f420a5..7396571f2b 100644 --- a/packages/agent-bff/test/cli-core.test.ts +++ b/packages/agent-bff/test/cli-core.test.ts @@ -259,6 +259,59 @@ 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(); + } + }); + + 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', () => { 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-page.test.ts b/packages/agent-bff/test/docs/docs-page.test.ts new file mode 100644 index 0000000000..3840995451 --- /dev/null +++ b/packages/agent-bff/test/docs/docs-page.test.ts @@ -0,0 +1,531 @@ +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 ORIGIN = 'https://bff.example.com'; +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; + resolveText(response: { ok: boolean; status: number; text: string }): 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, + Object, + Error, + window: { location: { origin: ORIGIN } }, + 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)) }); + }, + resolveText({ ok, status, text }) { + resolveFetch({ ok, status, text: () => Promise.resolve(text) }); + }, + 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](); + }, + // 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 => { + setImmediate(resolve); + }), + }; +} + +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 () => { + 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 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(); + + page.submit(' '); + + expect({ shown: page.errorShown(), requests: page.pending.length }).toEqual({ + shown: true, + requests: 0, + }); + }); + }); +}); + +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 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); + + 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 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'`); + }); + + 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' }); + + expect(page.rendered).toEqual({ openapi: '3.1.0' }); + }); +}); 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..70290dc619 --- /dev/null +++ b/packages/agent-bff/test/docs/docs-routes.test.ts @@ -0,0 +1,213 @@ +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'; +import { REDOC_THEME } from '../../src/docs/docs-theme'; + +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 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); + + 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 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); + + 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'); + }); + + 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', () => { + 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 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'); + + 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/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(); + }); +}); 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"