From 8835869c69db43ac9144cffa1c1c0ca292810c64 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 18 Mar 2026 15:42:00 -0400 Subject: [PATCH 1/7] Add RBAC badges to docs Swagger UI --- app/app/docs/components/SwaggerUI.tsx | 48 +++++++++- app/app/docs/components/swagger-rbac.test.tsx | 56 ++++++++++++ app/app/docs/components/swagger-rbac.tsx | 88 +++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 app/app/docs/components/swagger-rbac.test.tsx create mode 100644 app/app/docs/components/swagger-rbac.tsx diff --git a/app/app/docs/components/SwaggerUI.tsx b/app/app/docs/components/SwaggerUI.tsx index d147e144..049be3a8 100644 --- a/app/app/docs/components/SwaggerUI.tsx +++ b/app/app/docs/components/SwaggerUI.tsx @@ -4,6 +4,8 @@ import dynamic from 'next/dynamic'; import { useEffect, useState } from 'react'; import 'swagger-ui-react/swagger-ui.css'; +import { createSwaggerRbacPlugin } from './swagger-rbac'; + // Dynamically import swagger-ui-react to avoid SSR issues const SwaggerUIReact = dynamic(() => import('swagger-ui-react'), { ssr: false, @@ -18,6 +20,8 @@ interface SwaggerUIProps { specUrl?: string; } +const swaggerRbacPlugin = createSwaggerRbacPlugin(); + export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIProps) { const [mounted, setMounted] = useState(false); @@ -72,6 +76,42 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP border-radius: 8px; } + .swagger-ui-wrapper .swagger-ui .archestra-rbac-permissions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 0 16px 14px; + margin-top: -2px; + } + + .swagger-ui-wrapper .swagger-ui .archestra-rbac-permissions__label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #6b7280; + } + + .swagger-ui-wrapper .swagger-ui .archestra-rbac-permissions__badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .swagger-ui-wrapper .swagger-ui .archestra-rbac-permissions__badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 9999px; + border: 1px solid #d1d5db; + background: #ffffff; + color: #374151; + font-size: 12px; + font-weight: 600; + line-height: 1.4; + } + .swagger-ui-wrapper .swagger-ui .opblock.opblock-get { border-color: #93c5fd; background: rgba(59, 130, 246, 0.05); @@ -123,7 +163,13 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP display: none; } `} - + ); } diff --git a/app/app/docs/components/swagger-rbac.test.tsx b/app/app/docs/components/swagger-rbac.test.tsx new file mode 100644 index 00000000..2919bd18 --- /dev/null +++ b/app/app/docs/components/swagger-rbac.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { SwaggerRbacPermissions, extractRequiredPermissions } from './swagger-rbac'; + +describe('extractRequiredPermissions', () => { + it('reads x-required-permissions from plain objects', () => { + expect( + extractRequiredPermissions({ + operationProps: { + op: { + 'x-required-permissions': { + allOf: ['toolPolicy:read', 'team:update'], + }, + }, + }, + }) + ).toEqual(['toolPolicy:read', 'team:update']); + }); + + it('reads x-required-permissions from immutable-style get() records', () => { + const makeRecord = (value: Record) => ({ + get: (key: string) => value[key], + }); + + expect( + extractRequiredPermissions({ + operationProps: makeRecord({ + op: makeRecord({ + 'x-required-permissions': makeRecord({ + allOf: { + toJS: () => ['mcpRegistry:read'], + }, + }), + }), + }), + }) + ).toEqual(['mcpRegistry:read']); + }); +}); + +describe('SwaggerRbacPermissions', () => { + it('renders permission badges', () => { + render(); + + expect(screen.getByTestId('swagger-rbac-permissions')).toBeInTheDocument(); + expect(screen.getByText('Requires')).toBeInTheDocument(); + expect(screen.getByText('toolPolicy:read')).toBeInTheDocument(); + }); + + it('renders nothing when there are no permissions', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/app/app/docs/components/swagger-rbac.tsx b/app/app/docs/components/swagger-rbac.tsx new file mode 100644 index 00000000..8d9a2675 --- /dev/null +++ b/app/app/docs/components/swagger-rbac.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React from 'react'; + +// === Exports === + +export function SwaggerRbacPermissions({ permissions }: { permissions: string[] }) { + if (permissions.length === 0) { + return null; + } + + return ( +
+ Requires +
+ {permissions.map((permission) => ( + + {permission} + + ))} +
+
+ ); +} + +export function createSwaggerRbacPlugin() { + return () => ({ + wrapComponents: { + OperationSummary: (Original: React.ComponentType) => (props: unknown) => { + const permissions = extractRequiredPermissions(props); + + return ( + <> + + + + ); + }, + }, + }); +} + +export function extractRequiredPermissions(operationSummaryProps: unknown): string[] { + const operationProps = getRecordValue(operationSummaryProps, 'operationProps'); + const operation = getRecordValue(operationProps, 'op'); + const extension = getRecordValue(operation, 'x-required-permissions'); + const allOf = getRecordValue(extension, 'allOf'); + + if (Array.isArray(allOf)) { + return allOf.filter((value): value is string => typeof value === 'string'); + } + + if (hasToJs(allOf)) { + const values = allOf.toJS(); + if (Array.isArray(values)) { + return values.filter((value): value is string => typeof value === 'string'); + } + } + + return []; +} + +// === Internal helpers === + +function getRecordValue(source: unknown, key: string): unknown { + if (!source) { + return undefined; + } + + if (typeof source === 'object' && source !== null && key in source) { + return (source as Record)[key]; + } + + if ( + typeof source === 'object' && + source !== null && + 'get' in source && + typeof (source as { get?: unknown }).get === 'function' + ) { + return (source as { get: (value: string) => unknown }).get(key); + } + + return undefined; +} + +function hasToJs(value: unknown): value is { toJS: () => unknown } { + return typeof value === 'object' && value !== null && 'toJS' in value && typeof value.toJS === 'function'; +} From 7b7cfcd04e831a7de6787b2929d332c42544bc5a Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 18 Mar 2026 16:28:53 -0400 Subject: [PATCH 2/7] Add Swagger playground mode --- app/app/docs/components/SwaggerUI.tsx | 183 +++++++++++++++++- .../components/swagger-playground.test.ts | 80 ++++++++ app/app/docs/components/swagger-playground.ts | 136 +++++++++++++ app/app/docs/components/swagger-rbac.test.tsx | 34 +++- app/app/docs/components/swagger-rbac.tsx | 86 +++++--- 5 files changed, 480 insertions(+), 39 deletions(-) create mode 100644 app/app/docs/components/swagger-playground.test.ts create mode 100644 app/app/docs/components/swagger-playground.ts diff --git a/app/app/docs/components/SwaggerUI.tsx b/app/app/docs/components/SwaggerUI.tsx index 049be3a8..5c36cc9f 100644 --- a/app/app/docs/components/SwaggerUI.tsx +++ b/app/app/docs/components/SwaggerUI.tsx @@ -4,6 +4,15 @@ import dynamic from 'next/dynamic'; import { useEffect, useState } from 'react'; import 'swagger-ui-react/swagger-ui.css'; +import { + DEFAULT_PLAYGROUND_SETTINGS, + SWAGGER_PLAYGROUND_STORAGE_KEY, + type SwaggerPlaygroundSettings, + type SwaggerRequest, + applyPlaygroundSettings, + parseStoredPlaygroundSettings, + serializePlaygroundSettings, +} from './swagger-playground'; import { createSwaggerRbacPlugin } from './swagger-rbac'; // Dynamically import swagger-ui-react to avoid SSR issues @@ -21,14 +30,35 @@ interface SwaggerUIProps { } const swaggerRbacPlugin = createSwaggerRbacPlugin(); +const SUPPORTED_SUBMIT_METHODS: Array<'delete' | 'get' | 'head' | 'options' | 'patch' | 'post' | 'put'> = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head', + 'options', +]; export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIProps) { const [mounted, setMounted] = useState(false); + const [playground, setPlayground] = useState(DEFAULT_PLAYGROUND_SETTINGS); useEffect(() => { setMounted(true); + + const storedSettings = window.localStorage.getItem(SWAGGER_PLAYGROUND_STORAGE_KEY); + setPlayground(parseStoredPlaygroundSettings(storedSettings)); }, []); + useEffect(() => { + if (!mounted) { + return; + } + + window.localStorage.setItem(SWAGGER_PLAYGROUND_STORAGE_KEY, serializePlaygroundSettings(playground)); + }, [mounted, playground]); + if (!mounted) { return (
@@ -41,6 +71,78 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP
+
+
+
Playground Mode
+ +
+

+ Route requests to your backend and inject your raw Archestra API key into the Authorization{' '} + header. Do not prefix the key with Bearer. +

+
+
+ + + setPlayground((current) => ({ + ...current, + baseUrl: event.target.value, + })) + } + placeholder="http://localhost:9000" + type="text" + value={playground.baseUrl} + /> +
+
+ + + setPlayground((current) => ({ + ...current, + apiKey: event.target.value, + })) + } + placeholder="Paste API key" + type="password" + value={playground.apiKey} + /> +
+
+

+ Try it out is enabled only while playground mode is on. Settings stay in your browser on this machine. +

+
+ applyPlaygroundSettings({ + request: request as SwaggerRequest, + settings: playground, + specUrl, + }) + } + supportedSubmitMethods={playground.enabled ? SUPPORTED_SUBMIT_METHODS : []} + tryItOutEnabled={playground.enabled} />
); diff --git a/app/app/docs/components/swagger-playground.test.ts b/app/app/docs/components/swagger-playground.test.ts new file mode 100644 index 00000000..76cbd777 --- /dev/null +++ b/app/app/docs/components/swagger-playground.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_PLAYGROUND_SETTINGS, + applyPlaygroundSettings, + parseStoredPlaygroundSettings, + rewriteRequestUrl, +} from './swagger-playground'; + +describe('rewriteRequestUrl', () => { + it('rewrites absolute request URLs to the configured base URL', () => { + expect( + rewriteRequestUrl({ + baseUrl: 'http://localhost:9000/', + requestUrl: 'http://localhost:3001/api/tools?limit=10', + }) + ).toBe('http://localhost:9000/api/tools?limit=10'); + }); + + it('rewrites relative request URLs to the configured base URL', () => { + expect( + rewriteRequestUrl({ + baseUrl: 'http://localhost:9000', + requestUrl: '/api/tools', + }) + ).toBe('http://localhost:9000/api/tools'); + }); +}); + +describe('applyPlaygroundSettings', () => { + it('rewrites operation requests and injects the raw Authorization header', () => { + expect( + applyPlaygroundSettings({ + request: { + headers: { + Accept: 'application/json', + }, + url: 'http://localhost:3001/api/tools', + }, + settings: { + ...DEFAULT_PLAYGROUND_SETTINGS, + apiKey: 'test-api-key', + baseUrl: 'http://localhost:9000', + enabled: true, + }, + specUrl: '/docs/openapi.json', + }) + ).toEqual({ + headers: { + Accept: 'application/json', + Authorization: 'test-api-key', + }, + url: 'http://localhost:9000/api/tools', + }); + }); + + it('leaves the spec fetch request alone', () => { + expect( + applyPlaygroundSettings({ + request: { + url: 'http://localhost:3001/docs/openapi.json', + }, + settings: { + ...DEFAULT_PLAYGROUND_SETTINGS, + enabled: true, + }, + specUrl: '/docs/openapi.json', + }) + ).toEqual({ + url: 'http://localhost:3001/docs/openapi.json', + }); + }); +}); + +describe('parseStoredPlaygroundSettings', () => { + it('falls back to defaults when storage is empty or invalid', () => { + expect(parseStoredPlaygroundSettings(null)).toEqual(DEFAULT_PLAYGROUND_SETTINGS); + expect(parseStoredPlaygroundSettings('{')).toEqual(DEFAULT_PLAYGROUND_SETTINGS); + }); +}); diff --git a/app/app/docs/components/swagger-playground.ts b/app/app/docs/components/swagger-playground.ts new file mode 100644 index 00000000..79ad32d7 --- /dev/null +++ b/app/app/docs/components/swagger-playground.ts @@ -0,0 +1,136 @@ +'use client'; + +// === Exports === + +export const DEFAULT_PLAYGROUND_BASE_URL = 'http://localhost:9000'; + +export const DEFAULT_PLAYGROUND_SETTINGS: SwaggerPlaygroundSettings = { + apiKey: '', + baseUrl: DEFAULT_PLAYGROUND_BASE_URL, + enabled: false, +}; + +export const SWAGGER_PLAYGROUND_STORAGE_KEY = 'archestra-docs-swagger-playground'; + +export type SwaggerPlaygroundSettings = { + apiKey: string; + baseUrl: string; + enabled: boolean; +}; + +export type SwaggerRequest = { + headers?: Record; + loadSpec?: boolean; + url?: string; +}; + +export function applyPlaygroundSettings(params: { + request: SwaggerRequest; + settings: SwaggerPlaygroundSettings; + specUrl: string; +}): SwaggerRequest { + const { request, settings, specUrl } = params; + if (!settings.enabled || !request.url || request.loadSpec) { + return request; + } + + if (isSpecRequest(request.url, specUrl)) { + return request; + } + + const rewrittenUrl = rewriteRequestUrl({ + baseUrl: settings.baseUrl, + requestUrl: request.url, + }); + if (!rewrittenUrl) { + return request; + } + + const headers = { + ...(request.headers ?? {}), + }; + const apiKey = settings.apiKey.trim(); + if (apiKey) { + headers.Authorization = apiKey; + } + + return { + ...request, + headers, + url: rewrittenUrl, + }; +} + +export function parseStoredPlaygroundSettings(value: string | null): SwaggerPlaygroundSettings { + if (!value) { + return DEFAULT_PLAYGROUND_SETTINGS; + } + + try { + const parsed = JSON.parse(value); + if (!isPlaygroundSettings(parsed)) { + return DEFAULT_PLAYGROUND_SETTINGS; + } + + return { + apiKey: parsed.apiKey, + baseUrl: parsed.baseUrl || DEFAULT_PLAYGROUND_BASE_URL, + enabled: parsed.enabled, + }; + } catch { + return DEFAULT_PLAYGROUND_SETTINGS; + } +} + +export function serializePlaygroundSettings(settings: SwaggerPlaygroundSettings): string { + return JSON.stringify(settings); +} + +export function rewriteRequestUrl(params: { baseUrl: string; requestUrl: string }): string | null { + const normalizedBaseUrl = normalizeBaseUrl(params.baseUrl); + if (!normalizedBaseUrl) { + return null; + } + + const parsedUrl = tryParseUrl(params.requestUrl); + if (parsedUrl) { + return `${normalizedBaseUrl}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; + } + + if (params.requestUrl.startsWith('/')) { + return `${normalizedBaseUrl}${params.requestUrl}`; + } + + return `${normalizedBaseUrl}/${params.requestUrl.replace(/^\/+/, '')}`; +} + +// === Internal helpers === + +function isSpecRequest(requestUrl: string, specUrl: string): boolean { + const parsedUrl = tryParseUrl(requestUrl); + const pathname = parsedUrl?.pathname ?? requestUrl; + + return pathname === specUrl || pathname.endsWith(specUrl); +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, ''); +} + +function tryParseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function isPlaygroundSettings(value: unknown): value is SwaggerPlaygroundSettings { + if (!value || typeof value !== 'object') { + return false; + } + + const record = value as Record; + + return typeof record.enabled === 'boolean' && typeof record.baseUrl === 'string' && typeof record.apiKey === 'string'; +} diff --git a/app/app/docs/components/swagger-rbac.test.tsx b/app/app/docs/components/swagger-rbac.test.tsx index 2919bd18..d85aeaf5 100644 --- a/app/app/docs/components/swagger-rbac.test.tsx +++ b/app/app/docs/components/swagger-rbac.test.tsx @@ -10,12 +10,16 @@ describe('extractRequiredPermissions', () => { operationProps: { op: { 'x-required-permissions': { - allOf: ['toolPolicy:read', 'team:update'], + permissions: ['toolPolicy:read', 'team:update'], + note: 'Checked dynamically', }, }, }, }) - ).toEqual(['toolPolicy:read', 'team:update']); + ).toEqual({ + note: 'Checked dynamically', + permissions: ['toolPolicy:read', 'team:update'], + }); }); it('reads x-required-permissions from immutable-style get() records', () => { @@ -28,28 +32,44 @@ describe('extractRequiredPermissions', () => { operationProps: makeRecord({ op: makeRecord({ 'x-required-permissions': makeRecord({ - allOf: { + permissions: { toJS: () => ['mcpRegistry:read'], }, }), }), }), }) - ).toEqual(['mcpRegistry:read']); + ).toEqual({ + note: undefined, + permissions: ['mcpRegistry:read'], + }); }); }); describe('SwaggerRbacPermissions', () => { it('renders permission badges', () => { - render(); + render(); expect(screen.getByTestId('swagger-rbac-permissions')).toBeInTheDocument(); - expect(screen.getByText('Requires')).toBeInTheDocument(); + expect(screen.getByText('RBAC')).toBeInTheDocument(); expect(screen.getByText('toolPolicy:read')).toBeInTheDocument(); }); + it('renders note-only RBAC metadata', () => { + render( + + ); + + expect(screen.getByText('None (no additional RBAC permission required)')).toBeInTheDocument(); + }); + it('renders nothing when there are no permissions', () => { - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); diff --git a/app/app/docs/components/swagger-rbac.tsx b/app/app/docs/components/swagger-rbac.tsx index 8d9a2675..806af023 100644 --- a/app/app/docs/components/swagger-rbac.tsx +++ b/app/app/docs/components/swagger-rbac.tsx @@ -4,35 +4,21 @@ import React from 'react'; // === Exports === -export function SwaggerRbacPermissions({ permissions }: { permissions: string[] }) { - if (permissions.length === 0) { - return null; - } - - return ( -
- Requires -
- {permissions.map((permission) => ( - - {permission} - - ))} -
-
- ); -} +export type SwaggerRbacMetadata = { + note?: string; + permissions: string[]; +}; export function createSwaggerRbacPlugin() { return () => ({ wrapComponents: { OperationSummary: (Original: React.ComponentType) => (props: unknown) => { - const permissions = extractRequiredPermissions(props); + const metadata = extractRequiredPermissions(props); return ( <> - + ); }, @@ -40,24 +26,58 @@ export function createSwaggerRbacPlugin() { }); } -export function extractRequiredPermissions(operationSummaryProps: unknown): string[] { +export function extractRequiredPermissions(operationSummaryProps: unknown): SwaggerRbacMetadata { const operationProps = getRecordValue(operationSummaryProps, 'operationProps'); const operation = getRecordValue(operationProps, 'op'); const extension = getRecordValue(operation, 'x-required-permissions'); - const allOf = getRecordValue(extension, 'allOf'); - - if (Array.isArray(allOf)) { - return allOf.filter((value): value is string => typeof value === 'string'); + const permissions = getRecordValue(extension, 'permissions'); + const note = getStringValue(getRecordValue(extension, 'note')); + + if (Array.isArray(permissions)) { + return { + note, + permissions: permissions.filter((value): value is string => typeof value === 'string'), + }; } - if (hasToJs(allOf)) { - const values = allOf.toJS(); + if (hasToJs(permissions)) { + const values = permissions.toJS(); if (Array.isArray(values)) { - return values.filter((value): value is string => typeof value === 'string'); + return { + note, + permissions: values.filter((value): value is string => typeof value === 'string'), + }; } } - return []; + return { + note, + permissions: [], + }; +} + +export function SwaggerRbacPermissions({ metadata }: { metadata: SwaggerRbacMetadata }) { + if (metadata.permissions.length === 0 && !metadata.note) { + return null; + } + + return ( +
+ RBAC +
+ {metadata.permissions.length > 0 ? ( +
+ {metadata.permissions.map((permission) => ( + + {permission} + + ))} +
+ ) : null} + {metadata.note ? {metadata.note} : null} +
+
+ ); } // === Internal helpers === @@ -86,3 +106,11 @@ function getRecordValue(source: unknown, key: string): unknown { function hasToJs(value: unknown): value is { toJS: () => unknown } { return typeof value === 'object' && value !== null && 'toJS' in value && typeof value.toJS === 'function'; } + +function getStringValue(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + + return undefined; +} From ac7a9a115320812f22b3ca25d503cb26fe3927dc Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 18 Mar 2026 16:29:38 -0400 Subject: [PATCH 3/7] Remove unused Swagger doc exports --- app/app/docs/components/swagger-playground.ts | 2 +- app/app/docs/components/swagger-rbac.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/app/docs/components/swagger-playground.ts b/app/app/docs/components/swagger-playground.ts index 79ad32d7..c25bacfa 100644 --- a/app/app/docs/components/swagger-playground.ts +++ b/app/app/docs/components/swagger-playground.ts @@ -2,7 +2,7 @@ // === Exports === -export const DEFAULT_PLAYGROUND_BASE_URL = 'http://localhost:9000'; +const DEFAULT_PLAYGROUND_BASE_URL = 'http://localhost:9000'; export const DEFAULT_PLAYGROUND_SETTINGS: SwaggerPlaygroundSettings = { apiKey: '', diff --git a/app/app/docs/components/swagger-rbac.tsx b/app/app/docs/components/swagger-rbac.tsx index 806af023..ee0e1b9f 100644 --- a/app/app/docs/components/swagger-rbac.tsx +++ b/app/app/docs/components/swagger-rbac.tsx @@ -4,7 +4,7 @@ import React from 'react'; // === Exports === -export type SwaggerRbacMetadata = { +type SwaggerRbacMetadata = { note?: string; permissions: string[]; }; From de0fc71dfea25000fed27470ea1f1cbdcbe283d5 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 18 Mar 2026 17:20:09 -0400 Subject: [PATCH 4/7] Improve Swagger playground updates --- app/app/docs/components/SwaggerUI.tsx | 51 +++++++++++++++++-- .../components/swagger-playground.test.ts | 9 ++++ app/app/docs/components/swagger-playground.ts | 11 +++- app/app/docs/components/swagger-rbac.tsx | 15 +----- 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/app/app/docs/components/SwaggerUI.tsx b/app/app/docs/components/SwaggerUI.tsx index 5c36cc9f..6d3b78e5 100644 --- a/app/app/docs/components/SwaggerUI.tsx +++ b/app/app/docs/components/SwaggerUI.tsx @@ -39,16 +39,21 @@ const SUPPORTED_SUBMIT_METHODS: Array<'delete' | 'get' | 'head' | 'options' | 'p 'head', 'options', ]; +const PLAYGROUND_APPLY_DEBOUNCE_MS = 350; export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIProps) { const [mounted, setMounted] = useState(false); const [playground, setPlayground] = useState(DEFAULT_PLAYGROUND_SETTINGS); + const [appliedPlayground, setAppliedPlayground] = + useState(DEFAULT_PLAYGROUND_SETTINGS); useEffect(() => { setMounted(true); const storedSettings = window.localStorage.getItem(SWAGGER_PLAYGROUND_STORAGE_KEY); - setPlayground(parseStoredPlaygroundSettings(storedSettings)); + const parsedSettings = parseStoredPlaygroundSettings(storedSettings); + setPlayground(parsedSettings); + setAppliedPlayground(parsedSettings); }, []); useEffect(() => { @@ -59,6 +64,23 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP window.localStorage.setItem(SWAGGER_PLAYGROUND_STORAGE_KEY, serializePlaygroundSettings(playground)); }, [mounted, playground]); + useEffect(() => { + if (!mounted) { + return; + } + + const timeoutId = window.setTimeout(() => { + setAppliedPlayground(playground); + }, PLAYGROUND_APPLY_DEBOUNCE_MS); + + return () => window.clearTimeout(timeoutId); + }, [mounted, playground]); + + const isApplyingPlaygroundSettings = + playground.enabled !== appliedPlayground.enabled || + playground.baseUrl !== appliedPlayground.baseUrl || + playground.apiKey !== appliedPlayground.apiKey; + if (!mounted) { return (
@@ -143,6 +165,12 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP color: #6b7280; } + .swagger-ui-wrapper .archestra-swagger-playground__status { + margin-top: 10px; + font-size: 13px; + color: #2563eb; + } + .swagger-ui-wrapper .swagger-ui { font-family: inherit; } @@ -263,6 +291,12 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP border-radius: 6px; } + .swagger-ui-wrapper .swagger-ui input[disabled], + .swagger-ui-wrapper .swagger-ui select[disabled], + .swagger-ui-wrapper .swagger-ui textarea[disabled] { + cursor: text; + } + .swagger-ui-wrapper .swagger-ui .model-box { border-radius: 8px; } @@ -332,20 +366,29 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP

Try it out is enabled only while playground mode is on. Settings stay in your browser on this machine.

+ {isApplyingPlaygroundSettings ? ( +

Updating playground…

+ ) : null}
applyPlaygroundSettings({ request: request as SwaggerRequest, - settings: playground, + settings: appliedPlayground, specUrl, }) } - supportedSubmitMethods={playground.enabled ? SUPPORTED_SUBMIT_METHODS : []} - tryItOutEnabled={playground.enabled} + supportedSubmitMethods={appliedPlayground.enabled ? SUPPORTED_SUBMIT_METHODS : []} + tryItOutEnabled={appliedPlayground.enabled} />
); diff --git a/app/app/docs/components/swagger-playground.test.ts b/app/app/docs/components/swagger-playground.test.ts index 76cbd777..2ef5a85e 100644 --- a/app/app/docs/components/swagger-playground.test.ts +++ b/app/app/docs/components/swagger-playground.test.ts @@ -25,6 +25,15 @@ describe('rewriteRequestUrl', () => { }) ).toBe('http://localhost:9000/api/tools'); }); + + it('adds http:// when the configured base URL is missing a scheme', () => { + expect( + rewriteRequestUrl({ + baseUrl: 'localhost:9000', + requestUrl: '/api/tools', + }) + ).toBe('http://localhost:9000/api/tools'); + }); }); describe('applyPlaygroundSettings', () => { diff --git a/app/app/docs/components/swagger-playground.ts b/app/app/docs/components/swagger-playground.ts index c25bacfa..57ca67d5 100644 --- a/app/app/docs/components/swagger-playground.ts +++ b/app/app/docs/components/swagger-playground.ts @@ -114,7 +114,16 @@ function isSpecRequest(requestUrl: string, specUrl: string): boolean { } function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.trim().replace(/\/+$/, ''); + const trimmedBaseUrl = baseUrl.trim(); + if (!trimmedBaseUrl) { + return ''; + } + + const baseUrlWithScheme = /^[a-z]+:\/\//i.test(trimmedBaseUrl) + ? trimmedBaseUrl + : `http://${trimmedBaseUrl}`; + + return baseUrlWithScheme.replace(/\/+$/, ''); } function tryParseUrl(value: string): URL | null { diff --git a/app/app/docs/components/swagger-rbac.tsx b/app/app/docs/components/swagger-rbac.tsx index ee0e1b9f..05a31df4 100644 --- a/app/app/docs/components/swagger-rbac.tsx +++ b/app/app/docs/components/swagger-rbac.tsx @@ -10,20 +10,7 @@ type SwaggerRbacMetadata = { }; export function createSwaggerRbacPlugin() { - return () => ({ - wrapComponents: { - OperationSummary: (Original: React.ComponentType) => (props: unknown) => { - const metadata = extractRequiredPermissions(props); - - return ( - <> - - - - ); - }, - }, - }); + return () => ({}); } export function extractRequiredPermissions(operationSummaryProps: unknown): SwaggerRbacMetadata { From d93905455c44250e7dcc3bf53e9b6f01502b08ed Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 18 Mar 2026 17:28:46 -0400 Subject: [PATCH 5/7] wip --- .../components/swagger-playground.test.ts | 34 +++++++++++++++++++ app/app/docs/components/swagger-playground.ts | 17 +++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/app/app/docs/components/swagger-playground.test.ts b/app/app/docs/components/swagger-playground.test.ts index 2ef5a85e..a42296c1 100644 --- a/app/app/docs/components/swagger-playground.test.ts +++ b/app/app/docs/components/swagger-playground.test.ts @@ -5,6 +5,7 @@ import { applyPlaygroundSettings, parseStoredPlaygroundSettings, rewriteRequestUrl, + serializePlaygroundSettings, } from './swagger-playground'; describe('rewriteRequestUrl', () => { @@ -86,4 +87,37 @@ describe('parseStoredPlaygroundSettings', () => { expect(parseStoredPlaygroundSettings(null)).toEqual(DEFAULT_PLAYGROUND_SETTINGS); expect(parseStoredPlaygroundSettings('{')).toEqual(DEFAULT_PLAYGROUND_SETTINGS); }); + + it('drops any previously persisted apiKey value', () => { + expect( + parseStoredPlaygroundSettings( + JSON.stringify({ + apiKey: 'old-secret', + baseUrl: 'http://localhost:9000', + enabled: true, + }) + ) + ).toEqual({ + apiKey: '', + baseUrl: 'http://localhost:9000', + enabled: true, + }); + }); +}); + +describe('serializePlaygroundSettings', () => { + it('persists only non-sensitive playground settings', () => { + expect( + serializePlaygroundSettings({ + apiKey: 'secret-key', + baseUrl: 'http://localhost:9000', + enabled: true, + }) + ).toBe( + JSON.stringify({ + baseUrl: 'http://localhost:9000', + enabled: true, + }) + ); + }); }); diff --git a/app/app/docs/components/swagger-playground.ts b/app/app/docs/components/swagger-playground.ts index 57ca67d5..bd27e7ab 100644 --- a/app/app/docs/components/swagger-playground.ts +++ b/app/app/docs/components/swagger-playground.ts @@ -73,7 +73,7 @@ export function parseStoredPlaygroundSettings(value: string | null): SwaggerPlay } return { - apiKey: parsed.apiKey, + apiKey: '', baseUrl: parsed.baseUrl || DEFAULT_PLAYGROUND_BASE_URL, enabled: parsed.enabled, }; @@ -83,7 +83,10 @@ export function parseStoredPlaygroundSettings(value: string | null): SwaggerPlay } export function serializePlaygroundSettings(settings: SwaggerPlaygroundSettings): string { - return JSON.stringify(settings); + return JSON.stringify({ + baseUrl: settings.baseUrl, + enabled: settings.enabled, + }); } export function rewriteRequestUrl(params: { baseUrl: string; requestUrl: string }): string | null { @@ -134,12 +137,18 @@ function tryParseUrl(value: string): URL | null { } } -function isPlaygroundSettings(value: unknown): value is SwaggerPlaygroundSettings { +function isPlaygroundSettings( + value: unknown, +): value is Pick & { apiKey?: string } { if (!value || typeof value !== 'object') { return false; } const record = value as Record; - return typeof record.enabled === 'boolean' && typeof record.baseUrl === 'string' && typeof record.apiKey === 'string'; + return ( + typeof record.enabled === 'boolean' && + typeof record.baseUrl === 'string' && + (record.apiKey === undefined || typeof record.apiKey === 'string') + ); } From 124f2e5feb2c9dd37cb8466d57ffbe10dbf91e85 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 18 Mar 2026 17:30:09 -0400 Subject: [PATCH 6/7] wip --- app/app/docs/components/SwaggerUI.tsx | 7 +++---- app/app/docs/components/swagger-playground.ts | 6 ++---- app/app/docs/components/swagger-rbac.tsx | 2 -- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/app/docs/components/SwaggerUI.tsx b/app/app/docs/components/SwaggerUI.tsx index 6d3b78e5..eb9906f8 100644 --- a/app/app/docs/components/SwaggerUI.tsx +++ b/app/app/docs/components/SwaggerUI.tsx @@ -44,8 +44,7 @@ const PLAYGROUND_APPLY_DEBOUNCE_MS = 350; export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIProps) { const [mounted, setMounted] = useState(false); const [playground, setPlayground] = useState(DEFAULT_PLAYGROUND_SETTINGS); - const [appliedPlayground, setAppliedPlayground] = - useState(DEFAULT_PLAYGROUND_SETTINGS); + const [appliedPlayground, setAppliedPlayground] = useState(DEFAULT_PLAYGROUND_SETTINGS); useEffect(() => { setMounted(true); @@ -373,10 +372,10 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP & { apiKey?: string } { if (!value || typeof value !== 'object') { return false; diff --git a/app/app/docs/components/swagger-rbac.tsx b/app/app/docs/components/swagger-rbac.tsx index 05a31df4..4e190012 100644 --- a/app/app/docs/components/swagger-rbac.tsx +++ b/app/app/docs/components/swagger-rbac.tsx @@ -1,7 +1,5 @@ 'use client'; -import React from 'react'; - // === Exports === type SwaggerRbacMetadata = { From f393a2feb8e45cb9bca90c7f887f6fd98b905188 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 18 Mar 2026 17:36:12 -0400 Subject: [PATCH 7/7] wip --- app/app/docs/components/SwaggerUI.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/app/docs/components/SwaggerUI.tsx b/app/app/docs/components/SwaggerUI.tsx index eb9906f8..91d45e9c 100644 --- a/app/app/docs/components/SwaggerUI.tsx +++ b/app/app/docs/components/SwaggerUI.tsx @@ -304,6 +304,18 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP padding: 12px; } + .swagger-ui-wrapper .swagger-ui .markdown a, + .swagger-ui-wrapper .swagger-ui .renderedMarkdown a { + color: #2563eb; + text-decoration: underline; + text-underline-offset: 2px; + } + + .swagger-ui-wrapper .swagger-ui .markdown a:hover, + .swagger-ui-wrapper .swagger-ui .renderedMarkdown a:hover { + color: #1d4ed8; + } + /* Hide the info/description section since we have our own */ .swagger-ui-wrapper .swagger-ui .info { display: none;