diff --git a/README.md b/README.md index 2926c1f..82908dd 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,14 @@ device-code flow entirely in the UI—there is no terminal command and no user-created Microsoft Entra application registration. Workbench supports Key Vault references for: -| Authentication profile | Key Vault-backed fields | -| ------------------------ | ------------------------------- | -| Bearer token | Token | -| Basic authentication | Password | -| API key | Key value | -| OAuth client credentials | Client secret | -| OAuth password | Client secret and password | -| OAuth refresh token | Client secret and refresh token | +| Authentication profile | Key Vault-backed fields | +| ------------------------ | ------------------------------------------- | +| Bearer token | Token | +| Basic authentication | Password | +| API key | Key value | +| OAuth client credentials | Client ID and client secret | +| OAuth password | Client ID, client secret, and password | +| OAuth refresh token | Client ID, client secret, and refresh token | Each reference identifies a vault URL, secret name, and optional exact version. Omitting the version follows the latest secret, enabling rotation without @@ -213,9 +213,11 @@ Read [Workflows and assertions](docs/workflows-and-assertions.md). Prerequisite: Docker Desktop or Docker Engine with Compose. +Download or copy [`docker-compose.yml`](docker-compose.yml) into an empty +directory, then start Workbench using the published release image. Cloning the +repository is not required. + ```bash -git clone https://github.com/josh-uk/workbench.git -cd workbench docker compose up -d ``` @@ -234,9 +236,11 @@ docker compose down The `workbench_postgres_data`, `workbench_backups`, and `workbench_azure_cli` volumes preserve the database, logical backups, and -optional Azure sign-in respectively. Compose defaults use development -credentials; set unique `POSTGRES_*` values before exposing the database beyond -the local Docker network. +optional Azure sign-in respectively. Compose pulls +`ghcr.io/josh-uk/workbench:latest` by default. Set `WORKBENCH_IMAGE` to pin a +version such as `ghcr.io/josh-uk/workbench:1.1.1`. Compose defaults use +development credentials; set unique `POSTGRES_*` values before exposing the +database beyond the local Docker network. ### Container images @@ -247,7 +251,7 @@ Every verified merge to `master` publishes a non-root, multi-platform image for ghcr.io/josh-uk/workbench ``` -Images receive `latest` and full-commit-SHA tags. Version tags such as `v1.1.0` +Images receive `latest` and full-commit-SHA tags. Version tags such as `v1.1.1` also publish semantic-version tags, an SBOM, build provenance, and a GitHub release. GitHub Container Registry visibility is managed separately from the public repository. @@ -274,6 +278,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build | Name | Purpose | Compose default | | --------------------------- | ---------------------------------------------- | ------------------------------------ | +| `WORKBENCH_IMAGE` | Published Workbench image or pinned version | `ghcr.io/josh-uk/workbench:latest` | | `DATABASE_URL` | Server-only PostgreSQL connection URL | Generated from the PostgreSQL values | | `POSTGRES_DB` | Local database name | `workbench` | | `POSTGRES_USER` | Local database user | `workbench` | diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ae594fb..38737d1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,6 +1,8 @@ services: app: + image: workbench-dev build: + context: . target: development command: npm run dev -- --hostname 0.0.0.0 environment: diff --git a/docker-compose.yml b/docker-compose.yml index a29c709..303fb8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,7 @@ name: workbench services: app: - build: - context: . - target: runner + image: ${WORKBENCH_IMAGE:-ghcr.io/josh-uk/workbench:latest} init: true restart: unless-stopped environment: diff --git a/docs/authentication.md b/docs/authentication.md index 6d8d28c..6ab9ddf 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -28,17 +28,17 @@ timeout, TLS, and response-size controls. ## Azure Key Vault credential sources -The following secret-bearing fields can use either **Stored in Workbench** or +The following credential fields can use either **Stored in Workbench** or **Azure Key Vault**: -| Profile | Supported Key Vault fields | -| -------------------------- | ------------------------------- | -| Bearer token | Token | -| Basic authentication | Password | -| API key in header or query | Key value | -| OAuth client credentials | Client secret | -| OAuth password | Client secret and password | -| OAuth refresh token | Client secret and refresh token | +| Profile | Supported Key Vault fields | +| -------------------------- | ------------------------------------------- | +| Bearer token | Token | +| Basic authentication | Password | +| API key in header or query | Key value | +| OAuth client credentials | Client ID and client secret | +| OAuth password | Client ID, client secret, and password | +| OAuth refresh token | Client ID, client secret, and refresh token | Request-derived authentication does not need a Key Vault source because it receives its value from a saved request output. @@ -78,7 +78,7 @@ versions. Supplying a version keeps the profile pinned until the reference is edited. Only public Azure Key Vault hostnames ending in `.vault.azure.net` are accepted -in v1.1.0. The hostname may resolve through a private endpoint, but the +in v1.1.1. The hostname may resolve through a private endpoint, but the Workbench container must have the required DNS and network route. Sovereign Azure clouds are not yet supported. diff --git a/package-lock.json b/package-lock.json index 5cc6395..5aec819 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "workbench", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workbench", - "version": "1.1.0", + "version": "1.1.1", "dependencies": { "ajv": "8.20.0", "class-variance-authority": "0.7.1", diff --git a/package.json b/package.json index e8251ab..ae634da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workbench", - "version": "1.1.0", + "version": "1.1.1", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/globals.css b/src/app/globals.css index 108cd4d..860ff23 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -55,6 +55,10 @@ border-color: var(--border); } +html { + font-size: 110%; +} + body { background: var(--background); color: var(--foreground); diff --git a/src/features/authentication/azure/http.test.ts b/src/features/authentication/azure/http.test.ts new file mode 100644 index 0000000..8ba124f --- /dev/null +++ b/src/features/authentication/azure/http.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { AzureAuthenticationError } from "./domain"; +import { assertTrustedMutation } from "./http"; + +function mutationRequest(url: string, headers: Record = {}) { + return new Request(url, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: "{}", + }); +} + +describe("Azure HTTP mutation protection", () => { + it("accepts an origin matching the public Host header", () => { + const request = mutationRequest( + "http://0.0.0.0:3000/api/configuration/azure/login", + { host: "localhost:3000", origin: "http://localhost:3000" }, + ); + + expect(() => assertTrustedMutation(request)).not.toThrow(); + }); + + it("accepts an origin supplied by a TLS reverse proxy", () => { + const request = mutationRequest( + "http://app:3000/api/configuration/azure/login", + { + host: "app:3000", + origin: "https://workbench.example.com", + "x-forwarded-host": "workbench.example.com", + "x-forwarded-proto": "https", + }, + ); + + expect(() => assertTrustedMutation(request)).not.toThrow(); + }); + + it("rejects a different origin", () => { + const request = mutationRequest( + "http://0.0.0.0:3000/api/configuration/azure/login", + { host: "localhost:3000", origin: "https://example.com" }, + ); + + expect(() => assertTrustedMutation(request)).toThrowError( + AzureAuthenticationError, + ); + }); + + it("rejects requests identified by the browser as cross-site", () => { + const request = mutationRequest( + "http://localhost:3000/api/configuration/azure/login", + { + host: "localhost:3000", + origin: "http://localhost:3000", + "sec-fetch-site": "cross-site", + }, + ); + + expect(() => assertTrustedMutation(request)).toThrowError( + AzureAuthenticationError, + ); + }); + + it("continues to require JSON mutations", () => { + const request = new Request( + "http://localhost:3000/api/configuration/azure/login", + { method: "POST", body: "tenant=contoso" }, + ); + + expect(() => assertTrustedMutation(request)).toThrow( + "Azure requests must use JSON.", + ); + }); +}); diff --git a/src/features/authentication/azure/http.ts b/src/features/authentication/azure/http.ts index ecc92cc..fcb1122 100644 --- a/src/features/authentication/azure/http.ts +++ b/src/features/authentication/azure/http.ts @@ -12,7 +12,11 @@ export function assertTrustedMutation(request: Request) { ); } const origin = request.headers.get("origin"); - if (origin && origin !== new URL(request.url).origin) { + const fetchSite = request.headers.get("sec-fetch-site"); + if ( + fetchSite === "cross-site" || + (origin && !requestOrigins(request).has(origin)) + ) { throw new AzureAuthenticationError( "Cross-origin Azure requests are not allowed.", "AZURE_REQUEST_FORBIDDEN", @@ -20,6 +24,42 @@ export function assertTrustedMutation(request: Request) { } } +function requestOrigins(request: Request) { + const requestUrl = new URL(request.url); + const origins = new Set([requestUrl.origin]); + const host = request.headers.get("host"); + const forwardedHost = firstForwardedValue( + request.headers.get("x-forwarded-host"), + ); + const forwardedProtocol = firstForwardedValue( + request.headers.get("x-forwarded-proto"), + ); + + addOrigin(origins, requestUrl.protocol, host); + addOrigin(origins, forwardedProtocol, forwardedHost ?? host); + return origins; +} + +function firstForwardedValue(value: string | null) { + return value?.split(",", 1)[0]?.trim() || null; +} + +function addOrigin( + origins: Set, + protocol: string | null, + host: string | null, +) { + const normalizedProtocol = protocol?.replace(/:$/, ""); + if (!normalizedProtocol || !host || !/^https?$/.test(normalizedProtocol)) { + return; + } + try { + origins.add(new URL(`${normalizedProtocol}://${host}`).origin); + } catch { + // Invalid forwarding metadata is never trusted as an allowed origin. + } +} + export function azureErrorResponse(error: unknown) { const message = error instanceof ZodError diff --git a/src/features/authentication/domain.test.ts b/src/features/authentication/domain.test.ts index ea5c394..26cc134 100644 --- a/src/features/authentication/domain.test.ts +++ b/src/features/authentication/domain.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { defaultAuthConfiguration, parseAuthConfiguration, - secretFieldsForAuthType, + referencedFieldsForAuthType, } from "./domain"; describe("authentication secret references", () => { @@ -14,6 +14,7 @@ describe("authentication secret references", () => { token: null, password: null, key: null, + clientId: null, clientSecret: null, refreshToken: null, }); @@ -38,12 +39,33 @@ describe("authentication secret references", () => { ); }); + it("clears a stored client ID when Key Vault owns it", () => { + const configuration = parseAuthConfiguration({ + ...defaultAuthConfiguration(), + clientId: "must-not-remain", + secretReferences: { + clientId: { + provider: "azure_key_vault", + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "oauth-client-id", + version: "", + }, + }, + }); + + expect(configuration.clientId).toBe(""); + expect(configuration.secretReferences.clientId?.secretName).toBe( + "oauth-client-id", + ); + }); + it("resolves only fields used by each authentication type", () => { - expect(secretFieldsForAuthType("bearer")).toEqual(["token"]); - expect(secretFieldsForAuthType("oauth2_password")).toEqual([ + expect(referencedFieldsForAuthType("bearer")).toEqual(["token"]); + expect(referencedFieldsForAuthType("oauth2_password")).toEqual([ + "clientId", "clientSecret", "password", ]); - expect(secretFieldsForAuthType("request_derived")).toEqual([]); + expect(referencedFieldsForAuthType("request_derived")).toEqual([]); }); }); diff --git a/src/features/authentication/domain.ts b/src/features/authentication/domain.ts index bd7c9ac..9acd725 100644 --- a/src/features/authentication/domain.ts +++ b/src/features/authentication/domain.ts @@ -30,12 +30,18 @@ export const authSecretFieldNames = [ ] as const; export type AuthSecretField = (typeof authSecretFieldNames)[number]; +export const authReferenceFieldNames = [ + ...authSecretFieldNames, + "clientId", +] as const; +export type AuthReferenceField = (typeof authReferenceFieldNames)[number]; export const authSecretReferencesSchema = z .object({ token: keyVaultSecretReferenceSchema.nullable().default(null), password: keyVaultSecretReferenceSchema.nullable().default(null), key: keyVaultSecretReferenceSchema.nullable().default(null), + clientId: keyVaultSecretReferenceSchema.nullable().default(null), clientSecret: keyVaultSecretReferenceSchema.nullable().default(null), refreshToken: keyVaultSecretReferenceSchema.nullable().default(null), }) @@ -43,6 +49,7 @@ export const authSecretReferencesSchema = z token: null, password: null, key: null, + clientId: null, clientSecret: null, refreshToken: null, }); @@ -148,7 +155,9 @@ export const authSecretFields = new Set( authSecretFieldNames, ); -export function secretFieldsForAuthType(type: AuthType): AuthSecretField[] { +export function referencedFieldsForAuthType( + type: AuthType, +): AuthReferenceField[] { switch (type) { case "bearer": return ["token"]; @@ -158,11 +167,11 @@ export function secretFieldsForAuthType(type: AuthType): AuthSecretField[] { case "api_key_query": return ["key"]; case "oauth2_client_credentials": - return ["clientSecret"]; + return ["clientId", "clientSecret"]; case "oauth2_password": - return ["clientSecret", "password"]; + return ["clientId", "clientSecret", "password"]; case "oauth2_refresh_token": - return ["clientSecret", "refreshToken"]; + return ["clientId", "clientSecret", "refreshToken"]; default: return []; } @@ -172,7 +181,7 @@ export function normaliseReferencedSecrets( configuration: AuthProfileConfiguration, ) { const result = structuredClone(configuration); - for (const key of authSecretFieldNames) { + for (const key of authReferenceFieldNames) { if (result.secretReferences[key]) result[key] = ""; } return result; diff --git a/src/features/authentication/resolution.test.ts b/src/features/authentication/resolution.test.ts index 664efa3..be1133a 100644 --- a/src/features/authentication/resolution.test.ts +++ b/src/features/authentication/resolution.test.ts @@ -123,9 +123,9 @@ describe("Azure-backed authentication resolution", () => { mocks.getEffectiveAuthProfile.mockResolvedValue( profile("oauth2_client_credentials", { tokenUrl: "https://login.example.test/token", - clientId: "client-id", secretReferences: { ...defaultAuthConfiguration().secretReferences, + clientId: { ...reference, secretName: "client-id" }, clientSecret: reference, }, }), @@ -143,17 +143,27 @@ describe("Azure-backed authentication resolution", () => { expect(result.plan.headers[0]?.value).toBe("Bearer cached-access-token"); }); - it("resolves the Key Vault client secret immediately before OAuth renewal", async () => { + it("resolves the Key Vault client credentials immediately before OAuth renewal", async () => { + const clientIdReference = { ...reference, secretName: "client-id" }; + const clientSecretReference = { + ...reference, + secretName: "client-secret", + }; mocks.getEffectiveAuthProfile.mockResolvedValue( profile("oauth2_client_credentials", { tokenUrl: "https://login.example.test/token", - clientId: "client-id", secretReferences: { ...defaultAuthConfiguration().secretReferences, - clientSecret: reference, + clientId: clientIdReference, + clientSecret: clientSecretReference, }, }), ); + mocks.resolveKeyVaultSecret.mockImplementation(async (secretReference) => + secretReference.secretName === "client-id" + ? "key-vault-client-id" + : "key-vault-client-secret", + ); mocks.executeHttpRequest.mockResolvedValue({ statusCode: 200, rawBody: JSON.stringify({ @@ -164,13 +174,18 @@ describe("Azure-backed authentication resolution", () => { }); const result = await resolve(); - expect(mocks.resolveKeyVaultSecret).toHaveBeenCalledOnce(); + expect(mocks.resolveKeyVaultSecret).toHaveBeenCalledTimes(2); expect(mocks.executeHttpRequest).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ - content: expect.stringContaining("client_secret=key-vault-secret"), + content: expect.stringMatching( + /client_id=key-vault-client-id[\s\S]*client_secret=key-vault-client-secret/, + ), }), - secretValues: expect.arrayContaining(["key-vault-secret"]), + secretValues: expect.arrayContaining([ + "key-vault-client-id", + "key-vault-client-secret", + ]), }), expect.any(AbortSignal), ); diff --git a/src/features/authentication/resolution.ts b/src/features/authentication/resolution.ts index fc9a1fb..15ed435 100644 --- a/src/features/authentication/resolution.ts +++ b/src/features/authentication/resolution.ts @@ -15,7 +15,7 @@ import { AuthDomainError, type EffectiveAuthProfile, parseAuthConfiguration, - secretFieldsForAuthType, + referencedFieldsForAuthType, } from "@/features/authentication/domain"; import { resolveKeyVaultSecret } from "@/features/authentication/azure/key-vault"; import { AzureAuthenticationError } from "@/features/authentication/azure/domain"; @@ -62,7 +62,7 @@ function interpolateConfiguration( async function resolveReferencedSecrets( profile: EffectiveAuthProfile, signal: AbortSignal, - fields = secretFieldsForAuthType(profile.type), + fields = referencedFieldsForAuthType(profile.type), ): Promise { const configuration = structuredClone(profile.configuration); for (const key of fields) { @@ -159,6 +159,7 @@ async function requestOAuthToken( }, settings: sourcePlan.settings, secretValues: [ + config.secretReferences.clientId ? config.clientId : "", config.clientSecret, fields.password ?? "", fields.refresh_token ?? "", @@ -286,7 +287,8 @@ export async function resolveAuthentication(input: { }); } const usesCachedRefreshToken = Boolean(cached?.refreshToken); - const neededSecretFields = [ + const neededReferenceFields = [ + "clientId" as const, "clientSecret" as const, ...(!usesCachedRefreshToken && profile.type === "oauth2_password" ? (["password"] as const) @@ -298,7 +300,7 @@ export async function resolveAuthentication(input: { profile = await resolveReferencedSecrets( profile, input.signal, - neededSecretFields, + neededReferenceFields, ); const token = await requestOAuthToken( profile, diff --git a/src/features/exports/archive.test.ts b/src/features/exports/archive.test.ts index 28d9130..d1c1bd5 100644 --- a/src/features/exports/archive.test.ts +++ b/src/features/exports/archive.test.ts @@ -148,7 +148,7 @@ describe("versioned export archives", () => { secretMode: "exclude", }); - expect(manifest.appVersion).toBe("1.1.0"); + expect(manifest.appVersion).toBe("1.1.1"); expect(Buffer.from(archive).includes(Buffer.from("resolved-value"))).toBe( false, ); diff --git a/src/features/workbench/components/auth-profile-manager.test.tsx b/src/features/workbench/components/auth-profile-manager.test.tsx index b6db4ad..f2b3309 100644 --- a/src/features/workbench/components/auth-profile-manager.test.tsx +++ b/src/features/workbench/components/auth-profile-manager.test.tsx @@ -92,6 +92,91 @@ describe("AuthProfileManager", () => { ); }); + it("saves an Azure Key Vault source for an OAuth client ID", async () => { + const user = userEvent.setup(); + const configuration = { + profiles: [ + { + id: "a47ac10b-58cc-4372-a567-0e02b2c3d479", + workspaceId: null, + projectId: "b47ac10b-58cc-4372-a567-0e02b2c3d479", + tokenRequestId: null, + name: "Project OAuth", + type: "oauth2_client_credentials" as const, + configuration: { + ...defaultAuthConfiguration(), + tokenUrl: "https://auth.example.test/token", + clientId: "workbench-client", + clientSecret: "••••••••", + }, + inherited: false, + overridden: false, + }, + ], + tokenRequests: [], + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => ({ + ok: true, + json: async () => + String(input).includes("/azure") + ? { status: "disconnected", cliAvailable: true } + : structuredClone(configuration), + })), + ); + vi.mocked(saveAuthProfileAction).mockResolvedValue({ + ok: true, + data: { id: configuration.profiles[0].id }, + }); + + render( + , + ); + + await screen.findByDisplayValue("Project OAuth"); + await user.selectOptions( + screen.getByLabelText("Client ID source"), + "azure_key_vault", + ); + await user.type( + screen.getByLabelText("Client ID vault URL"), + "https://workbench-secrets.vault.azure.net/", + ); + await user.type( + screen.getByLabelText("Client ID secret name"), + "oauth-client-id", + ); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(saveAuthProfileAction).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: expect.objectContaining({ + clientId: "", + secretReferences: expect.objectContaining({ + clientId: expect.objectContaining({ + provider: "azure_key_vault", + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "oauth-client-id", + }), + }), + }), + }), + ), + ); + }); + it("saves an Azure Key Vault source without a stored secret", async () => { const user = userEvent.setup(); const configuration = { diff --git a/src/features/workbench/components/auth-profile-manager.tsx b/src/features/workbench/components/auth-profile-manager.tsx index e0d4ed9..fc59376 100644 --- a/src/features/workbench/components/auth-profile-manager.tsx +++ b/src/features/workbench/components/auth-profile-manager.tsx @@ -21,7 +21,7 @@ import { type AuthConfiguration, type AuthProfileConfiguration, type AuthProfileDetail, - type AuthSecretField, + type AuthReferenceField, type AuthType, authTypes, defaultAuthConfiguration, @@ -101,16 +101,18 @@ function TextField({ ); } -function SecretField({ +function VaultBackedField({ configuration, label, name, onChange, + secret = true, }: { configuration: AuthProfileConfiguration; label: string; - name: AuthSecretField; + name: AuthReferenceField; onChange: (configuration: AuthProfileConfiguration) => void; + secret?: boolean; }) { const reference = configuration.secretReferences[name]; const [testing, setTesting] = useState(false); @@ -164,7 +166,7 @@ function SecretField({

{label}

-
{testResult ? (

onChange({ ...configuration, [name]: event.target.value }) } - type="password" + type={secret ? "password" : "text"} value={configuration[name]} /> )} @@ -411,7 +413,7 @@ export function AuthProfileManager({

-

+

{project ? `${project.name} configuration` : "Workspace configuration"} @@ -465,7 +467,7 @@ export function AuthProfileManager({ type="button" > {profile.name} - + {profile.type.replaceAll("_", " ")} ·{" "} {profile.inherited ? "workspace inherited" @@ -552,7 +554,7 @@ export function AuthProfileManager({ {draft.type === "bearer" ? ( <> - - - - - - ) : null} {draft.type === "oauth2_refresh_token" ? ( -

- + FORMAT V1 @@ -596,7 +596,7 @@ export function BackupManager({ aria-hidden="true" className="size-4 text-accent" /> - + {backup.name} diff --git a/src/features/workbench/components/collection-import-manager.tsx b/src/features/workbench/components/collection-import-manager.tsx index 5d0112d..54aa194 100644 --- a/src/features/workbench/components/collection-import-manager.tsx +++ b/src/features/workbench/components/collection-import-manager.tsx @@ -116,14 +116,14 @@ function RequestPreview({ } type="checkbox" /> - + {request.method} {request.name} - + {request.url} @@ -261,7 +261,7 @@ export function CollectionImportManager({
-

+

{project.name} · Portable imports

@@ -326,18 +326,20 @@ export function CollectionImportManager({

{item.requestCount}

-

Imported items

+

+ Imported items +

{item.linkedRequestCount}

-

+

Linked requests

-

+

Imported {new Date(item.importedAt).toLocaleString()}

@@ -481,7 +483,7 @@ export function CollectionImportManager({
-

+

Validated {formatLabels[preview.format]} {preview.formatVersion ? ` ${preview.formatVersion}` : ""}

@@ -493,7 +495,7 @@ export function CollectionImportManager({ {preview.projectVariables.length} variables

-
+
{preview.authProfiles.length} auth diff --git a/src/features/workbench/components/command-palette.tsx b/src/features/workbench/components/command-palette.tsx index f01fdd8..8e6b540 100644 --- a/src/features/workbench/components/command-palette.tsx +++ b/src/features/workbench/components/command-palette.tsx @@ -186,7 +186,7 @@ export function CommandPalette({ {action.shortcut ? ( diff --git a/src/features/workbench/components/openapi-manager.tsx b/src/features/workbench/components/openapi-manager.tsx index e5e71d3..b86c357 100644 --- a/src/features/workbench/components/openapi-manager.tsx +++ b/src/features/workbench/components/openapi-manager.tsx @@ -227,7 +227,7 @@ function OperationPreview({ /> {tag} -