From 0215840099306c31d224d8b6e49cb07dbd6e1542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Mon, 15 Jun 2026 12:12:15 +0800 Subject: [PATCH 1/7] fix(security): harden untrusted spec input (SSRF allowlist, size/quantifier/proto guards) - url-guard: same-origin + private-network blocklist for external $ref; the library's safeUrlResolver is a no-op in browsers, so enforce it ourselves - parser: custom http resolver vets every external $ref, degrades blocked ones to empty schema and surfaces a warning; options built per spec source origin - fetch-utils: cap untrusted response bodies (25MB) via Content-Length + streaming - use-openapi: openapi_url scheme allowlist (http/https only); capped reads - generate-example: reject oversized regex quantifiers before RandExp, slice output - resolve-schema: skip __proto__/constructor/prototype keys when merging Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/tools/MultiEnvDiffView.tsx | 5 +- src/components/tools/ProjectToolsView.tsx | 3 +- src/hooks/use-openapi.ts | 8 +- src/lib/fetch-utils.ts | 51 ++++++++++++ src/lib/openapi/generate-example.ts | 23 +++++- src/lib/openapi/parser.ts | 81 ++++++++++++++++--- src/lib/openapi/resolve-schema.ts | 16 +++- src/lib/openapi/url-guard.test.ts | 77 ++++++++++++++++++ src/lib/openapi/url-guard.ts | 98 +++++++++++++++++++++++ 9 files changed, 339 insertions(+), 23 deletions(-) create mode 100644 src/lib/fetch-utils.ts create mode 100644 src/lib/openapi/url-guard.test.ts create mode 100644 src/lib/openapi/url-guard.ts diff --git a/src/components/tools/MultiEnvDiffView.tsx b/src/components/tools/MultiEnvDiffView.tsx index 80a9dbf..67df6c0 100644 --- a/src/components/tools/MultiEnvDiffView.tsx +++ b/src/components/tools/MultiEnvDiffView.tsx @@ -7,6 +7,7 @@ import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from "@ import type { OpenAPIDiffResult } from "@/lib/openapi/diff" import type { OpenAPISpec } from "@/lib/openapi/types" import { getErrorMessage, normalizeParsedSpec, parseSpecText, parseValidatedSpec } from "@/lib/openapi/parser" +import { readResponseTextCapped } from "@/lib/fetch-utils" import { useEnvironments } from "@/hooks/use-environments" import { useOpenAPIContext } from "@/contexts/OpenAPIContext" import { GroupedDiffResult, getEnvSpecUrl } from "./ProjectToolsView" @@ -119,9 +120,9 @@ export function MultiEnvDiffView({ spec }: { spec?: OpenAPISpec | undefined }) { if (!url) throw new Error(`Cannot construct URL for ${opt.name}`) const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`) - const text = await response.text() + const text = await readResponseTextCapped(response) const parsed = parseSpecText(text) - const validated = await parseValidatedSpec(parsed) + const validated = await parseValidatedSpec(parsed, { sourceUrl: url }) parsedSpec = normalizeParsedSpec(validated.spec) } loadedSpecs.push({ key: opt.key, name: opt.name, spec: parsedSpec }) diff --git a/src/components/tools/ProjectToolsView.tsx b/src/components/tools/ProjectToolsView.tsx index 49b9a75..2e308fc 100644 --- a/src/components/tools/ProjectToolsView.tsx +++ b/src/components/tools/ProjectToolsView.tsx @@ -16,6 +16,7 @@ import type { } from "@/lib/openapi/diff" import type { OpenAPISpec } from "@/lib/openapi/types" import { getErrorMessage } from "@/lib/openapi/parser" +import { readResponseTextCapped } from "@/lib/fetch-utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -594,7 +595,7 @@ export function OpenAPIDiffView({ spec }: OpenAPIDiffViewProps) { try { const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`) - const text = await response.text() + const text = await readResponseTextCapped(response) worker.postMessage({ type: "parse-slot", requestId, slot, name, text }) } catch (error) { pendingSlotRequestsRef.current[slot] = null diff --git a/src/hooks/use-openapi.ts b/src/hooks/use-openapi.ts index b5a8d2a..0a4b51c 100644 --- a/src/hooks/use-openapi.ts +++ b/src/hooks/use-openapi.ts @@ -24,6 +24,8 @@ import { detectSpecType, parseAsyncAPIDocument } from "@/lib/asyncapi/parser" import { getEnvironmentRuntimes, getSpecSettings, putSpecFromDocument } from "@/lib/db" import { computeSpecId } from "@/lib/spec-id" import { isEmbeddedMode } from "@/lib/embedded" +import { isHttpUrl } from "@/lib/openapi/url-guard" +import { readResponseTextCapped } from "@/lib/fetch-utils" function extractRoutes(spec: OpenAPISpec, sourceSpec: OpenAPISpec): { routes: ParsedRoute[] @@ -132,7 +134,7 @@ export function useOpenAPI() { const yieldToUI = () => new Promise(r => requestAnimationFrame(() => setTimeout(r, 0))) const processOpenAPISpec = useCallback(async (input: string | OpenAPISpec, url: string, baseUrlOverride?: string) => { - const { spec: parsedSpec, sourceSpec, warnings } = await parseValidatedSpec(input) + const { spec: parsedSpec, sourceSpec, warnings } = await parseValidatedSpec(input, { sourceUrl: url }) if (warnings.length > 0) { toast.warning(i18n.t("toast.nonStandardProperties"), { duration: 8000 }) } @@ -194,7 +196,7 @@ export function useOpenAPI() { const loadFromUrl = useCallback(async (url: string, options?: { baseUrlOverride?: string; fetchAuth?: { username: string; password: string } }) => { if (!url.trim()) return - try { new URL(url) } catch { + if (!isHttpUrl(url)) { dispatch({ type: "SET_ERROR", error: i18n.t("error.invalidUrl") }) return } @@ -232,7 +234,7 @@ export function useOpenAPI() { } throw new Error(i18n.t("error.fetchHttp", { status: response.status, statusText: response.statusText })) } - const text = await response.text() + const text = await readResponseTextCapped(response) const specType = detectSpecType(text) if (specType === "asyncapi") { await processAsyncAPISpec(text, url) diff --git a/src/lib/fetch-utils.ts b/src/lib/fetch-utils.ts new file mode 100644 index 0000000..a75882b --- /dev/null +++ b/src/lib/fetch-utils.ts @@ -0,0 +1,51 @@ +// Bounded reading of untrusted responses. A malicious openapi_url (e.g. from a +// share link that auto-loads) could otherwise return a multi-GB body and exhaust +// the tab's memory before parsing even starts. + +export const MAX_SPEC_BYTES = 25 * 1024 * 1024 // 25 MB + +class ResponseTooLargeError extends Error { + constructor(maxBytes: number) { + super(`Response exceeds the maximum allowed size of ${Math.round(maxBytes / (1024 * 1024))} MB`) + this.name = "ResponseTooLargeError" + } +} + +/** + * Read a Response body as text while enforcing a hard byte cap. Rejects early via + * Content-Length when present, otherwise streams and aborts once the cap is hit. + */ +export async function readResponseTextCapped( + response: Response, + maxBytes: number = MAX_SPEC_BYTES, +): Promise { + const declared = response.headers.get("content-length") + if (declared && Number(declared) > maxBytes) { + throw new ResponseTooLargeError(maxBytes) + } + + if (!response.body) { + const text = await response.text() + if (text.length > maxBytes) throw new ResponseTooLargeError(maxBytes) + return text + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let received = 0 + let text = "" + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (value) { + received += value.byteLength + if (received > maxBytes) { + await reader.cancel() + throw new ResponseTooLargeError(maxBytes) + } + text += decoder.decode(value, { stream: true }) + } + } + text += decoder.decode() + return text +} diff --git a/src/lib/openapi/generate-example.ts b/src/lib/openapi/generate-example.ts index 177a686..8248fee 100644 --- a/src/lib/openapi/generate-example.ts +++ b/src/lib/openapi/generate-example.ts @@ -8,6 +8,22 @@ import phoneExamples from "libphonenumber-js/mobile/examples" const E164_COUNTRIES: CountryCode[] = ["US", "GB", "CN", "JP", "KR", "DE", "FR", "IN", "AU", "BR"] +// Upper bound for explicit regex quantifiers we are willing to expand. `re.max` +// only caps open-ended quantifiers (*, +, {n,}); a bounded quantifier like +// `a{1000000}` from an untrusted spec would otherwise expand fully and OOM the tab. +const QUANTIFIER_LIMIT = 1000 + +function isSafePattern(pattern: string): boolean { + const re = /\{\s*(\d+)\s*(?:,\s*(\d*)\s*)?\}/g + let m: RegExpExecArray | null + while ((m = re.exec(pattern)) !== null) { + const lo = Number(m[1]) + const hi = m[2] !== undefined && m[2] !== "" ? Number(m[2]) : lo + if (lo > QUANTIFIER_LIMIT || hi > QUANTIFIER_LIMIT) return false + } + return true +} + function randomE164(): string { const country = faker.helpers.arrayElement(E164_COUNTRIES) return generatePhoneForCountry(country) @@ -58,11 +74,12 @@ function fakerForSchema(schema: SchemaObject): unknown { // String if (type === "string" || !type) { // 1. Pattern is the strongest constraint — always prefer it - if (schema.pattern) { + if (schema.pattern && isSafePattern(schema.pattern)) { try { const re = new RandExp(schema.pattern) - re.max = schema.maxLength ?? 20 - return re.gen() + re.max = Math.min(schema.maxLength ?? 20, QUANTIFIER_LIMIT) + const out = re.gen() + return schema.maxLength !== undefined ? out.slice(0, schema.maxLength) : out } catch { // Invalid pattern, fall through to format } diff --git a/src/lib/openapi/parser.ts b/src/lib/openapi/parser.ts index 7914797..8df725f 100644 --- a/src/lib/openapi/parser.ts +++ b/src/lib/openapi/parser.ts @@ -14,6 +14,7 @@ import type { SchemaObject, ServerObject, } from "./types" +import { isExternalRefAllowed, originOf } from "./url-guard" export const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const export type HttpMethod = (typeof HTTP_METHODS)[number] @@ -21,15 +22,55 @@ export type HttpMethod = (typeof HTTP_METHODS)[number] const globalScope = globalThis as typeof globalThis & { Buffer?: typeof Buffer } globalScope.Buffer ??= Buffer -const PARSER_OPTIONS = { - dereference: { - circular: "ignore", - }, - resolve: { - external: true, - file: false, - }, -} satisfies ParserOptions +// Origins an untrusted spec is allowed to pull external $ref from: the app's own +// origin plus the origin the spec itself was loaded from. Everything else (other +// hosts, internal/private addresses, non-http schemes) is refused. +function computeAllowedOrigins(sourceUrl?: string): string[] { + const origins = new Set() + if (typeof location !== "undefined" && location.origin && location.origin !== "null") { + origins.add(location.origin) + } + if (sourceUrl) { + const o = originOf(sourceUrl) + if (o) origins.add(o) + } + return [...origins] +} + +// Build parser options whose http resolver vets every external $ref. Disallowed +// refs are degraded to an empty schema (no request issued) and recorded in +// `blocked`; the library's built-in unsafe-URL guard is a no-op in browsers, so +// this is the actual SSRF defense. +function buildParserOptions(allowedOrigins: string[], blocked: Set): ParserOptions { + // @readme's ParserOptions only types `http.timeout`, but it forwards the full + // resolve config to @apidevtools/json-schema-ref-parser, whose http resolver + // honors canRead/read. Extracted to a variable so the literal excess-property + // check doesn't reject the (runtime-valid) canRead/read fields. + const httpResolver: { timeout?: number; canRead: RegExp; read: (file: { url: string }) => Promise } = { + canRead: /^https?:\/\//i, + async read(file: { url: string }): Promise { + if (!isExternalRefAllowed(file.url, allowedOrigins)) { + blocked.add(file.url) + return "{}" + } + const res = await fetch(file.url) + if (!res.ok) { + throw new Error(`Failed to fetch external $ref ${file.url}: ${res.status}`) + } + return await res.text() + }, + } + return { + dereference: { + circular: "ignore", + }, + resolve: { + external: true, + file: false, + http: httpResolver, + }, + } satisfies ParserOptions +} type ParserInput = Parameters[0] @@ -88,25 +129,39 @@ export function sanitizeNonStandardExtensions(spec: OpenAPISpec): { spec: OpenAP return { spec: result, warnings } } -export async function parseValidatedSpec(input: string | OpenAPISpec): Promise<{ +export async function parseValidatedSpec( + input: string | OpenAPISpec, + opts: { sourceUrl?: string } = {}, +): Promise<{ spec: OpenAPISpec sourceSpec: OpenAPISpec warnings: string[] }> { + const blocked = new Set() + const parserOptions = buildParserOptions(computeAllowedOrigins(opts.sourceUrl), blocked) + const parserInput = asParserInput(input) - const sourceSpec = await parseOpenAPIDocument(parserInput, PARSER_OPTIONS) as OpenAPISpec + const sourceSpec = await parseOpenAPIDocument(parserInput, parserOptions) as OpenAPISpec // Sanitize non-standard properties before validation const { spec: sanitizedSource, warnings } = sanitizeNonStandardExtensions(sourceSpec) const validationInput = asParserInput(cloneSpec(sanitizedSource)) - const validation = await validate(validationInput, PARSER_OPTIONS) + const validation = await validate(validationInput, parserOptions) if (!validation.valid) { throw new Error(formatValidationError(validation)) } const dereferenceInput = asParserInput(cloneSpec(sanitizedSource)) - const spec = await dereference(dereferenceInput, PARSER_OPTIONS) + const spec = await dereference(dereferenceInput, parserOptions) + + if (blocked.size > 0) { + const sample = [...blocked].slice(0, 5).join(", ") + warnings.push( + `Blocked ${blocked.size} external $ref pointer(s) targeting untrusted or internal URLs ` + + `(SSRF protection): ${sample}${blocked.size > 5 ? ", …" : ""}. These were replaced with empty schemas.`, + ) + } return { spec: spec as OpenAPISpec, diff --git a/src/lib/openapi/resolve-schema.ts b/src/lib/openapi/resolve-schema.ts index d9018ba..bfb72a6 100644 --- a/src/lib/openapi/resolve-schema.ts +++ b/src/lib/openapi/resolve-schema.ts @@ -1,5 +1,16 @@ import type { SchemaObject } from './types' +// Property names from untrusted specs must never be written as object keys +// directly, or `__proto__`/`constructor`/`prototype` would pollute the prototype. +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + +function safeAssign(target: Record, source: Record): void { + for (const [k, v] of Object.entries(source)) { + if (DANGEROUS_KEYS.has(k)) continue + target[k] = v + } +} + export function resolveEffectiveSchema(schema: SchemaObject | undefined | null): SchemaObject & { _nullable: boolean } { if (!schema || typeof schema !== 'object') return { ...(schema || {}), _nullable: false } as SchemaObject & { _nullable: boolean }; let s: SchemaObject = schema; @@ -9,11 +20,12 @@ export function resolveEffectiveSchema(schema: SchemaObject | undefined | null): for (const part of s.allOf) { if (!part || typeof part !== 'object') continue; const { properties, required, ...rest } = part as SchemaObject & { required?: string[] }; - Object.assign(merged, rest); + safeAssign(merged, rest); if (properties) { const existing = merged.properties || {}; const combined: Record = { ...existing }; for (const [pk, pv] of Object.entries(properties)) { + if (DANGEROUS_KEYS.has(pk)) continue; if (existing[pk] && typeof existing[pk] === 'object' && typeof pv === 'object') { combined[pk] = { ...existing[pk], ...pv }; } else { @@ -32,6 +44,7 @@ export function resolveEffectiveSchema(schema: SchemaObject | undefined | null): const existing = merged.properties || {}; const combined: Record = { ...existing }; for (const [pk, pv] of Object.entries(v as Record)) { + if (DANGEROUS_KEYS.has(pk)) continue; if (existing[pk] && typeof existing[pk] === 'object' && typeof pv === 'object') { combined[pk] = { ...existing[pk], ...pv }; } else { @@ -42,6 +55,7 @@ export function resolveEffectiveSchema(schema: SchemaObject | undefined | null): } else if (k === 'required') { merged.required = [...((merged as { required?: string[] }).required || []), ...(v as string[])]; } else { + if (DANGEROUS_KEYS.has(k)) continue; merged[k] = v; } } diff --git a/src/lib/openapi/url-guard.test.ts b/src/lib/openapi/url-guard.test.ts new file mode 100644 index 0000000..5e3b1a5 --- /dev/null +++ b/src/lib/openapi/url-guard.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest" +import { isPrivateOrLocalHost, isHttpUrl, isExternalRefAllowed, isCrossHostTarget } from "@/lib/openapi/url-guard" + +describe("url-guard", () => { + describe("isPrivateOrLocalHost", () => { + it("flags loopback / private / link-local IPv4", () => { + for (const h of ["127.0.0.1", "10.1.2.3", "192.168.0.1", "172.16.5.5", "172.31.0.1", "169.254.1.1", "0.0.0.0", "100.64.0.1"]) { + expect(isPrivateOrLocalHost(h)).toBe(true) + } + }) + it("allows public IPv4", () => { + for (const h of ["8.8.8.8", "1.1.1.1", "172.32.0.1", "172.15.0.1", "93.184.216.34"]) { + expect(isPrivateOrLocalHost(h)).toBe(false) + } + }) + it("flags loopback / ULA / link-local IPv6 (with or without brackets)", () => { + for (const h of ["::1", "[::1]", "fc00::1", "fd12::3", "fe80::1", "[fe80::1]", "::ffff:127.0.0.1"]) { + expect(isPrivateOrLocalHost(h)).toBe(true) + } + }) + it("flags internal hostnames and bare single-label names", () => { + for (const h of ["localhost", "foo.localhost", "service.local", "db.internal", "intranet"]) { + expect(isPrivateOrLocalHost(h)).toBe(true) + } + }) + it("allows normal public hostnames", () => { + for (const h of ["example.com", "api.example.com", "petstore.swagger.io"]) { + expect(isPrivateOrLocalHost(h)).toBe(false) + } + }) + }) + + describe("isHttpUrl", () => { + it("accepts http(s)", () => { + expect(isHttpUrl("https://example.com/openapi.json")).toBe(true) + expect(isHttpUrl("http://example.com")).toBe(true) + }) + it("rejects dangerous and malformed schemes", () => { + for (const u of ["javascript:alert(1)", "file:///etc/passwd", "data:text/html,x", "ftp://x", "not a url"]) { + expect(isHttpUrl(u)).toBe(false) + } + }) + }) + + describe("isExternalRefAllowed", () => { + const allowed = ["https://api.example.com"] + it("allows same-origin public refs", () => { + expect(isExternalRefAllowed("https://api.example.com/models.json", allowed)).toBe(true) + }) + it("blocks cross-origin refs", () => { + expect(isExternalRefAllowed("https://evil.example/x.json", allowed)).toBe(false) + }) + it("blocks internal targets even if origin would match scheme", () => { + expect(isExternalRefAllowed("http://127.0.0.1/x.json", ["http://127.0.0.1"])).toBe(false) + expect(isExternalRefAllowed("http://169.254.169.254/latest/meta-data", allowed)).toBe(false) + }) + it("blocks non-http schemes", () => { + expect(isExternalRefAllowed("file:///etc/passwd", allowed)).toBe(false) + }) + }) + + describe("isCrossHostTarget", () => { + const trusted = ["https://api.example.com"] + it("treats relative/unparseable targets as same-origin", () => { + expect(isCrossHostTarget("/oauth/token", trusted)).toBe(false) + }) + it("flags a different host", () => { + expect(isCrossHostTarget("https://evil.example/token", trusted)).toBe(true) + }) + it("allows a trusted host", () => { + expect(isCrossHostTarget("https://api.example.com/token", trusted)).toBe(false) + }) + it("never flags when there is no trust set", () => { + expect(isCrossHostTarget("https://anything.example", [])).toBe(false) + }) + }) +}) diff --git a/src/lib/openapi/url-guard.ts b/src/lib/openapi/url-guard.ts new file mode 100644 index 0000000..49fd0d6 --- /dev/null +++ b/src/lib/openapi/url-guard.ts @@ -0,0 +1,98 @@ +// Guards for untrusted URLs reachable from spec content / share links. +// apilot loads arbitrary third-party specs; their external $ref pointers and +// server URLs must be treated as untrusted to prevent SSRF / internal-network +// probing from the victim's browser. The library's own safeUrlResolver is a +// no-op in the browser, so we enforce these checks ourselves. + +/** + * True if the hostname points at a loopback, link-local, private, or otherwise + * internal target that an untrusted spec must not be able to reach. + */ +export function isPrivateOrLocalHost(hostname: string): boolean { + let h = hostname.toLowerCase().trim() + // Strip IPv6 brackets that URL.hostname keeps (e.g. "[::1]"). + if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1) + if (!h) return true + + // Hostname-based internal suffixes. + if (h === "localhost" || h.endsWith(".localhost")) return true + if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".home.arpa")) return true + + // IPv4 literal. + const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (ipv4) { + const a = Number(ipv4[1]) + const b = Number(ipv4[2]) + if (a === 0 || a === 127 || a === 10) return true + if (a === 169 && b === 254) return true // link-local + if (a === 192 && b === 168) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64.0.0/10 + return false + } + + // IPv6 literal. + if (h.includes(":")) { + if (h === "::1" || h === "::") return true + if (h.startsWith("fc") || h.startsWith("fd")) return true // unique-local fc00::/7 + if (h.startsWith("fe8") || h.startsWith("fe9") || h.startsWith("fea") || h.startsWith("feb")) return true // link-local fe80::/10 + const mapped = h.match(/::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i) + if (mapped) return isPrivateOrLocalHost(mapped[1]!) + return false + } + + // Bare single-label hostname (no dot) — likely an intranet name. + if (!h.includes(".")) return true + + return false +} + +/** True if the URL is a well-formed http(s) URL (rejects javascript:/file:/data: etc.). */ +export function isHttpUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + +/** + * Whether an external $ref URL embedded in an untrusted spec may be fetched. + * Allowed only when it is http(s), not pointing at an internal host, and its + * origin is in the trusted set (the spec's own source origin + the app origin). + */ +export function isExternalRefAllowed(refUrl: string, allowedOrigins: readonly string[]): boolean { + let u: URL + try { + u = new URL(refUrl) + } catch { + return false + } + if (u.protocol !== "http:" && u.protocol !== "https:") return false + if (isPrivateOrLocalHost(u.hostname)) return false + return allowedOrigins.includes(u.origin) +} + +/** Origin of a URL, or null if it cannot be parsed. */ +export function originOf(url: string): string | null { + try { + return new URL(url).origin + } catch { + return null + } +} + +/** + * Whether sending credentials to `targetUrl` crosses out of the trusted origin + * set (the spec's declared servers / the origin the user confirmed). Used to + * warn before auth headers or OAuth2 passwords are sent to an attacker host. + * A target that cannot be resolved to an absolute origin (e.g. a relative path) + * is treated as same-origin (not cross-host). + */ +export function isCrossHostTarget(targetUrl: string, trustedOrigins: readonly string[]): boolean { + const origin = originOf(targetUrl) + if (!origin) return false + if (trustedOrigins.length === 0) return false + return !trustedOrigins.includes(origin) +} From 3108f2cfac4bcac997027bcafbeeca39b47dfaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Mon, 15 Jun 2026 12:25:48 +0800 Subject: [PATCH 2/7] fix(security): close credential leak paths (curl injection, history redaction, oauth2/url guards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-snippet: POSIX single-quote-escape url/header/body in the curl fallback (the fallback fires on relative URLs, so it's a real paste-to-shell injection vector) - db: redact credential fields in stored history — request body, params, response body, and the duplicate response.requestBody; dynamic curl redaction now covers custom API-key header names; precise normalized key set avoids over-redaction - use-auth: reject non-http(s) and plaintext-http (non-loopback) OAuth2 tokenUrl - use-settings: validate auth_type via authTypeValue, stop reading auth_token from the URL, strip sensitive query params from the address bar after load - use-request: strip CR/LF from header values; warn once per host before sending credentials to a host not declared in the spec's servers - channels: mask the WS token in the URL preview (browser API forces query transport) - i18n: add tokenUrlInvalid/tokenUrlInsecure/credentialCrossHost across all locales Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 1 - src/components/channels/ChannelTestTab.tsx | 4 +- src/hooks/use-auth.ts | 18 ++++ src/hooks/use-request.ts | 25 ++++- src/hooks/use-settings.ts | 32 +++++-- src/lib/build-snippet.test.ts | 17 ++++ src/lib/build-snippet.ts | 11 ++- src/lib/db.test.ts | 45 +++++++++ src/lib/db.ts | 103 ++++++++++++++++++++- src/locales/en.ts | 3 + src/locales/ja.ts | 3 + src/locales/ko.ts | 3 + src/locales/zh_CN.ts | 3 + src/locales/zh_HK.ts | 3 + src/locales/zh_TW.ts | 3 + 15 files changed, 255 insertions(+), 19 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 877b9ee..4dba1a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -106,7 +106,6 @@ function AppContent() { useSettings({ setAuthType: auth.setAuthType, - setAuthToken: auth.setAuthToken, }, loadFromUrl) const isEmbedded = isEmbeddedMode() diff --git a/src/components/channels/ChannelTestTab.tsx b/src/components/channels/ChannelTestTab.tsx index eadaf0f..0eccd09 100644 --- a/src/components/channels/ChannelTestTab.tsx +++ b/src/components/channels/ChannelTestTab.tsx @@ -51,6 +51,8 @@ export function ChannelTestTab({ channel }: { channel: ParsedChannel }) { } url = url.replace(/\/$/, "") + address if (authToken) { + // Browsers can't set custom headers on WebSocket, so the token must go in the + // query string. It's masked in the URL preview to avoid shoulder-surfing. url += (url.includes("?") ? "&" : "?") + `token=${encodeURIComponent(authToken)}` } return url @@ -147,7 +149,7 @@ export function ChannelTestTab({ channel }: { channel: ParsedChannel }) { {/* URL preview + connect/disconnect */}
- {buildUrl() || "ws://..."} + {buildUrl().replace(/(token=)[^&]+/, "$1***") || "ws://..."} {ws.status === "disconnected" || ws.status === "error" ? ( - diff --git a/src/components/console/ConsoleListPage.tsx b/src/components/console/ConsoleListPage.tsx index 841e797..ebf7471 100644 --- a/src/components/console/ConsoleListPage.tsx +++ b/src/components/console/ConsoleListPage.tsx @@ -10,6 +10,7 @@ import { useAuthContext } from "@/contexts/AuthContext" import { useConsoleContext } from "@/contexts/ConsoleContext" import type { ConsoleResource } from "@/lib/console/types" import type { Parameter } from "@/lib/openapi/types" +import { PAGINATION_TOTAL_FIELDS } from "@/lib/console/schema-inference" import { Skeleton } from "@/components/ui/skeleton" import { ConsoleFilterBar } from "./ConsoleFilterBar" import { ConfirmDialog } from "./ConfirmDialog" @@ -89,9 +90,11 @@ export function ConsoleListPage({ resource, readOnly, layoutOverride }: { resour const extractTotalCount = useCallback((parsed: unknown) => { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { const obj = parsed as Record - for (const key of ["total", "count", "total_count", "totalCount", "totalItems"]) { - if (typeof obj[key] === "number") { - setTotalCount(obj[key] as number) + // Case-insensitive match, consistent with detectPagination's field set. + for (const key of PAGINATION_TOTAL_FIELDS) { + const match = Object.keys(obj).find(k => k.toLowerCase() === key) + if (match && typeof obj[match] === "number") { + setTotalCount(obj[match] as number) return } } diff --git a/src/components/console/PathParamFields.tsx b/src/components/console/PathParamFields.tsx new file mode 100644 index 0000000..5e5d923 --- /dev/null +++ b/src/components/console/PathParamFields.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Field, FieldLabel } from "@/components/ui/field" +import type { ParsedRoute, Parameter } from "@/lib/openapi/types" + +/** Path (`in: "path"`) parameters declared on a route, e.g. {id} in /users/{id}. */ +export function getPathParams(route: ParsedRoute): Parameter[] { + return (route.parameters ?? []).filter(p => p.in === "path") +} + +/** True if every required path parameter has a non-empty value. */ +export function hasAllRequiredPathParams(route: ParsedRoute, values: Record): boolean { + return getPathParams(route).every(p => !p.required || !!values[p.name]?.trim()) +} + +/** + * Inline editor for a route's path parameters with an optional "load" action. + * Detail/editor/config/stats templates render this so a resource whose read + * operation needs an id (GET /users/{id}) is actually usable instead of silently + * blank. Renders nothing when the route has no path parameters. + */ +export function PathParamFields({ + route, values, onChange, onLoad, loading, loadLabel, +}: { + route: ParsedRoute + values: Record + onChange: (next: Record) => void + onLoad?: () => void + loading?: boolean + loadLabel?: string +}) { + const { t } = useTranslation() + const params = getPathParams(route) + if (params.length === 0) return null + const ready = hasAllRequiredPathParams(route, values) + return ( +
+ {params.map(p => ( + + + {p.name}{p.required ? " *" : ""} + + onChange({ ...values, [p.name]: e.target.value })} + onKeyDown={e => { if (e.key === "Enter" && ready && onLoad) onLoad() }} + /> + + ))} + {onLoad && ( + + )} +
+ ) +} diff --git a/src/components/console/templates/ActionFormTemplate.tsx b/src/components/console/templates/ActionFormTemplate.tsx index fa636ec..55de9a3 100644 --- a/src/components/console/templates/ActionFormTemplate.tsx +++ b/src/components/console/templates/ActionFormTemplate.tsx @@ -9,6 +9,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { applyFieldLayout } from "@/lib/console/apply-layout" import type { FormFieldConfig, ResourceAction } from "@/lib/console/types" import { getRequestBodySchema } from "@/lib/console/schema-inference" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import { toast } from "sonner" import type { TemplateProps } from "./index" @@ -38,14 +39,17 @@ function ActionCard({ action, fieldConfigs }: { action: ResourceAction; fieldCon const { submitJson, loading } = useConsoleFetch() const [formData, setFormData] = useState({}) const [response, setResponse] = useState(null) + const [pathParams, setPathParams] = useState>({}) const rawSchema = getRequestBodySchema(action.route) const schema = rawSchema ? applyFieldLayout(rawSchema, fieldConfigs) : null + const routePathParams = getPathParams(action.route) + const canSubmit = hasAllRequiredPathParams(action.route, pathParams) const handleChange = useCallback((v: FormOutput) => setFormData(v), []) const handleSubmit = async () => { const body = schema ? JSON.stringify(formData) : "" - const { ok, response: resp } = await submitJson(action.route, body) + const { ok, response: resp } = await submitJson(action.route, body, pathParams) if (ok) toast.success(`${action.label}: OK`) else toast.error(`${action.label}: failed`) setResponse(resp) @@ -64,13 +68,16 @@ function ActionCard({ action, fieldConfigs }: { action: ResourceAction; fieldCon {action.route.description} )} - {schema && ( - - + {(routePathParams.length > 0 || schema) && ( + + {routePathParams.length > 0 && ( + + )} + {schema && } )} - diff --git a/src/components/console/templates/ConfigFormTemplate.tsx b/src/components/console/templates/ConfigFormTemplate.tsx index 1554a68..4c167ee 100644 --- a/src/components/console/templates/ConfigFormTemplate.tsx +++ b/src/components/console/templates/ConfigFormTemplate.tsx @@ -11,6 +11,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { applyFieldLayout } from "@/lib/console/apply-layout" import { stableEqual } from "@/lib/console/template-utils" import { ConfirmDialog } from "../ConfirmDialog" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import { toast } from "sonner" import type { TemplateProps } from "./index" @@ -25,23 +26,25 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps) const [formData, setFormData] = useState({}) const [error, setError] = useState(null) const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false) + const [pathParams, setPathParams] = useState>({}) const readOp = resource.operations.read const updateOp = resource.operations.update const rawSchema = resource.updateSchema ?? resource.detailSchema const schema = rawSchema ? applyFieldLayout(rawSchema, layout?.formFields) : null + const needsInput = !!readOp && getPathParams(readOp.route).length > 0 const dirty = useMemo(() => data !== null && !stableEqual(formData, data), [formData, data]) const fetchConfig = useCallback(async () => { - if (!readOp) return + if (!readOp || !hasAllRequiredPathParams(readOp.route, pathParams)) return setError(null) - const { data: parsed, error: err } = await fetchJson(readOp.route) + const { data: parsed, error: err } = await fetchJson(readOp.route, pathParams) if (parsed) { setData(parsed); setFormData(parsed) } setError(err) - }, [readOp, fetchJson]) + }, [readOp, fetchJson, pathParams]) - useEffect(() => { fetchConfig() }, [fetchConfig]) + useEffect(() => { if (!needsInput) fetchConfig() }, [needsInput, fetchConfig]) const handleChange = useCallback((v: FormOutput) => setFormData(v), []) @@ -52,7 +55,7 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps) const handleSave = async () => { if (!updateOp) return - const ok = await mutate(updateOp.route, { body: JSON.stringify(formData) }) + const ok = await mutate(updateOp.route, { body: JSON.stringify(formData), params: pathParams }) if (ok) { toast.success(t("console.updated")); fetchConfig() } else toast.error(t("console.updateFailed", { status: "" })) } @@ -71,6 +74,18 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps) + {needsInput && readOp && ( +
+ +
+ )} + {error && (
{error}
)} diff --git a/src/components/console/templates/DetailCardTemplate.tsx b/src/components/console/templates/DetailCardTemplate.tsx index 855a2da..96c4cc5 100644 --- a/src/components/console/templates/DetailCardTemplate.tsx +++ b/src/components/console/templates/DetailCardTemplate.tsx @@ -10,6 +10,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { applyDetailLayout } from "@/lib/console/apply-layout" import { ConsoleFormDialog } from "../ConsoleFormDialog" import { ConsoleActionButton } from "../ConsoleActionButton" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import type { TemplateProps } from "./index" export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) { @@ -19,19 +20,22 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) const { fetchJson, loading } = useConsoleFetch() const [data, setData] = useState | null>(null) const [error, setError] = useState(null) + const [pathParams, setPathParams] = useState>({}) const readOp = resource.operations.read const hasUpdate = !!resource.operations.update + const needsInput = !!readOp && getPathParams(readOp.route).length > 0 const fetchDetail = useCallback(async () => { - if (!readOp) return + if (!readOp || !hasAllRequiredPathParams(readOp.route, pathParams)) return setError(null) - const { data: parsed, error: err } = await fetchJson>(readOp.route) + const { data: parsed, error: err } = await fetchJson>(readOp.route, pathParams) setData(parsed) setError(err) - }, [readOp, fetchJson]) + }, [readOp, fetchJson, pathParams]) - useEffect(() => { fetchDetail() }, [fetchDetail]) + // Auto-load only when no path parameter input is required. + useEffect(() => { if (!needsInput) fetchDetail() }, [needsInput, fetchDetail]) return (
@@ -56,6 +60,18 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) + {needsInput && readOp && ( +
+ +
+ )} + {error && (
{error} @@ -92,7 +108,7 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) {resource.actions.length > 0 && (
{resource.actions.map((action, i) => ( - + ))}
)} diff --git a/src/components/console/templates/EditorSplitTemplate.tsx b/src/components/console/templates/EditorSplitTemplate.tsx index 45e1a15..03893c6 100644 --- a/src/components/console/templates/EditorSplitTemplate.tsx +++ b/src/components/console/templates/EditorSplitTemplate.tsx @@ -11,6 +11,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { applyDetailLayout, applyFieldLayout } from "@/lib/console/apply-layout" import { stableEqual } from "@/lib/console/template-utils" import { ConfirmDialog } from "../ConfirmDialog" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import { toast } from "sonner" import type { TemplateProps } from "./index" @@ -25,23 +26,25 @@ export function EditorSplitTemplate({ resource, layoutOverride }: TemplateProps) const [formData, setFormData] = useState({}) const [error, setError] = useState(null) const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false) + const [pathParams, setPathParams] = useState>({}) const readOp = resource.operations.read const updateOp = resource.operations.update const updateSchema = resource.updateSchema ? applyFieldLayout(resource.updateSchema, layout?.updateFields) : null + const needsInput = !!readOp && getPathParams(readOp.route).length > 0 const dirty = useMemo(() => data !== null && !stableEqual(formData, data), [formData, data]) const fetchDetail = useCallback(async () => { - if (!readOp) return + if (!readOp || !hasAllRequiredPathParams(readOp.route, pathParams)) return setError(null) - const { data: parsed, error: err } = await fetchJson>(readOp.route) + const { data: parsed, error: err } = await fetchJson>(readOp.route, pathParams) if (parsed) { setData(parsed); setFormData(parsed) } else setData(null) setError(err) - }, [readOp, fetchJson]) + }, [readOp, fetchJson, pathParams]) - useEffect(() => { fetchDetail() }, [fetchDetail]) + useEffect(() => { if (!needsInput) fetchDetail() }, [needsInput, fetchDetail]) const handleChange = useCallback((v: FormOutput) => setFormData(v), []) @@ -52,7 +55,7 @@ export function EditorSplitTemplate({ resource, layoutOverride }: TemplateProps) const handleSave = async () => { if (!updateOp) return - const ok = await mutate(updateOp.route, { body: JSON.stringify(formData) }) + const ok = await mutate(updateOp.route, { body: JSON.stringify(formData), params: pathParams }) if (ok) { toast.success(t("console.updated")); fetchDetail() } else toast.error(t("console.updateFailed", { status: "" })) } @@ -83,6 +86,16 @@ export function EditorSplitTemplate({ resource, layoutOverride }: TemplateProps) onConfirm={() => { setDiscardConfirmOpen(false); fetchDetail() }} /> + {needsInput && readOp && ( + + )} + {error && (
{error} diff --git a/src/components/console/templates/StatsDashboardTemplate.tsx b/src/components/console/templates/StatsDashboardTemplate.tsx index 646ca1f..3928cf7 100644 --- a/src/components/console/templates/StatsDashboardTemplate.tsx +++ b/src/components/console/templates/StatsDashboardTemplate.tsx @@ -12,6 +12,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { categorizeStats } from "@/lib/console/apply-layout" import { useTheme } from "next-themes" import { ConsoleActionButton } from "../ConsoleActionButton" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import type { TemplateProps } from "./index" ModuleRegistry.registerModules([AllCommunityModule]) @@ -23,20 +24,22 @@ export function StatsDashboardTemplate({ resource, layoutOverride }: TemplatePro const { fetchJson, loading } = useConsoleFetch() const [data, setData] = useState | null>(null) const [error, setError] = useState(null) + const [pathParams, setPathParams] = useState>({}) const readOp = resource.operations.read ?? resource.operations.list const action = !readOp ? resource.actions[0] : null + const activeRoute = readOp?.route ?? action?.route ?? null + const needsInput = !!activeRoute && getPathParams(activeRoute).length > 0 const fetchData = useCallback(async () => { - const route = readOp?.route ?? action?.route - if (!route) return + if (!activeRoute || !hasAllRequiredPathParams(activeRoute, pathParams)) return setError(null) - const { data: parsed, error: err } = await fetchJson>(route) + const { data: parsed, error: err } = await fetchJson>(activeRoute, pathParams) setData(parsed) setError(err) - }, [readOp, action, fetchJson]) + }, [activeRoute, fetchJson, pathParams]) - useEffect(() => { fetchData() }, [fetchData]) + useEffect(() => { if (!needsInput) fetchData() }, [needsInput, fetchData]) const statsConfig = layout?.statsConfig @@ -63,12 +66,22 @@ export function StatsDashboardTemplate({ resource, layoutOverride }: TemplatePro {t("console.autoRefresh", { seconds: refreshInterval })} ) : null}
- {resource.actions.map((a, i) => )} + {resource.actions.map((a, i) => )}
+ {needsInput && activeRoute && ( + + )} + {error && (
{error}
)} diff --git a/src/hooks/use-console-fetch.ts b/src/hooks/use-console-fetch.ts index 43e351a..11d9457 100644 --- a/src/hooks/use-console-fetch.ts +++ b/src/hooks/use-console-fetch.ts @@ -15,20 +15,22 @@ export interface SubmitJsonResult { export function useConsoleFetch() { const auth = useAuthContext() - const { sendRequest, loading } = useRequest(auth.getAuthHeaders) + const { sendRequest, loading, errorRef } = useRequest(auth.getAuthHeaders) const fetchJson = useCallback(async ( route: ParsedRoute, params?: Record, ): Promise> => { const result = await sendRequest(route, params ?? {}, "", "application/json") - if (!result) return { data: null, error: null } + // null = baseUrl missing / validation failure / abort. Surface the validation + // reason (errorRef) so templates don't render a silent blank; abort leaves it null. + if (!result) return { data: null, error: errorRef.current } if (result.status >= 200 && result.status < 300) { try { return { data: JSON.parse(result.body) as T, error: null } } - catch { return { data: null, error: null } } + catch (e) { return { data: null, error: e instanceof Error ? e.message : "Invalid JSON response" } } } return { data: null, error: `${result.status} ${result.statusText}` } - }, [sendRequest]) + }, [sendRequest, errorRef]) const submitJson = useCallback(async ( route: ParsedRoute, diff --git a/src/hooks/use-request.ts b/src/hooks/use-request.ts index f76c4cf..bc38c9c 100644 --- a/src/hooks/use-request.ts +++ b/src/hooks/use-request.ts @@ -28,6 +28,9 @@ export function useRequest(getAuthHeaders: () => Record) { const [loading, setLoading] = useState(false) const [response, setResponse] = useState(null) const [error, setError] = useState(null) + // Synchronous mirror of `error` so callers can read the latest failure reason + // immediately after sendRequest resolves null (state updates lag a render). + const errorRef = useRef(null) const abortRef = useRef(null) // Origins we've already warned about sending credentials to (warn once per host). const warnedHostsRef = useRef>(new Set()) @@ -89,14 +92,16 @@ export function useRequest(getAuthHeaders: () => Record) { ): Promise => { const baseUrl = state.baseUrl.replace(/\/$/, "") if (!baseUrl) { - setError(i18n.t("validation.baseUrl")) + errorRef.current = i18n.t("validation.baseUrl") + setError(errorRef.current) return null } const validationErrors = validateRequest(route, params, body, contentType, formData) const firstValidationError = validationErrors[0] if (firstValidationError) { - setError(firstValidationError.message) + errorRef.current = firstValidationError.message + setError(errorRef.current) return null } @@ -105,6 +110,7 @@ export function useRequest(getAuthHeaders: () => Record) { abortRef.current = controller setLoading(true) + errorRef.current = null setError(null) setResponse(null) @@ -249,6 +255,7 @@ export function useRequest(getAuthHeaders: () => Record) { loading, response, error, + errorRef, sendRequest, validateRequest, findTokenFields, diff --git a/src/lib/console/schema-inference.test.ts b/src/lib/console/schema-inference.test.ts new file mode 100644 index 0000000..eb01225 --- /dev/null +++ b/src/lib/console/schema-inference.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest" +import { detectPagination, inferListItemSchema } from "@/lib/console/schema-inference" +import type { ParsedRoute, SchemaObject } from "@/lib/openapi/types" + +function routeWithResponse(schema: SchemaObject): ParsedRoute { + return { responses: { "200": { content: { "application/json": { schema } } } } } as ParsedRoute +} + +describe("detectPagination (allOf / OAS 3.1)", () => { + it("detects items/total on an OAS 3.1 nullable object (type: [object, null])", () => { + const schema: SchemaObject = { + type: ["object", "null"], + properties: { + items: { type: ["array", "null"], items: { type: "object" } }, + total: { type: "integer" }, + }, + } + const p = detectPagination(schema) + expect(p.style).toBe("offset") + expect(p.itemsField).toBe("items") + expect(p.totalField).toBe("total") + }) + + it("detects pagination through an allOf composition", () => { + const schema: SchemaObject = { + allOf: [ + { type: "object", properties: { total: { type: "integer" } } }, + { type: "object", properties: { results: { type: "array", items: { type: "object" } } } }, + ], + } + const p = detectPagination(schema) + expect(p.itemsField).toBe("results") + expect(p.totalField).toBe("total") + }) + + it("returns none when there is no array field", () => { + const p = detectPagination({ type: "object", properties: { name: { type: "string" } } }) + expect(p.style).toBe("none") + }) +}) + +describe("inferListItemSchema (allOf / OAS 3.1)", () => { + it("unwraps an OAS 3.1 nullable array response", () => { + const route = routeWithResponse({ type: ["array", "null"], items: { type: "object", properties: { id: { type: "integer" } } } }) + const { schema } = inferListItemSchema(route) + expect(schema?.properties?.id).toBeDefined() + }) + + it("finds the list item schema inside an allOf paginated response", () => { + const route = routeWithResponse({ + allOf: [ + { type: "object", properties: { total: { type: "integer" } } }, + { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { name: { type: "string" } } } } } }, + ], + }) + const { schema, pagination } = inferListItemSchema(route) + expect(pagination.itemsField).toBe("items") + expect(schema?.properties?.name).toBeDefined() + }) +}) diff --git a/src/lib/console/schema-inference.ts b/src/lib/console/schema-inference.ts index 074d245..c0ac5aa 100644 --- a/src/lib/console/schema-inference.ts +++ b/src/lib/console/schema-inference.ts @@ -1,8 +1,15 @@ import type { ParsedRoute, SchemaObject, ResponseObject } from "@/lib/openapi/types" +import { resolveEffectiveSchema } from "@/lib/openapi/resolve-schema" import type { PaginationConfig } from "./types" +// Normalize a possibly-composed/3.1 schema to a primitive type string. +function normalizedType(schema: SchemaObject): string | undefined { + const t = resolveEffectiveSchema(schema).type + return Array.isArray(t) ? t[0] : t +} + const PAGINATION_ITEMS_FIELDS = ["items", "data", "results", "records", "rows", "list", "content", "entries"] -const PAGINATION_TOTAL_FIELDS = ["total", "count", "total_count", "totalcount", "totalitems", "total_items"] +export const PAGINATION_TOTAL_FIELDS = ["total", "count", "total_count", "totalcount", "totalitems", "total_items"] export function getRequestBodySchema(route: ParsedRoute): SchemaObject | null { const content = route.requestBody?.content @@ -30,20 +37,23 @@ function getResponseSchema(responses: Record): SchemaObj } export function detectPagination(schema: SchemaObject | null): PaginationConfig { - if (!schema || schema.type !== "object" || !schema.properties) { + // Resolve allOf / OAS 3.1 array-type (["object","null"]) before inspecting. + const resolved = schema ? resolveEffectiveSchema(schema) : null + if (!resolved || normalizedType(resolved) !== "object" || !resolved.properties) { return { style: "none", itemsField: null, totalField: null } } let itemsField: string | null = null let totalField: string | null = null - for (const key of Object.keys(schema.properties)) { - const prop = schema.properties[key] + for (const key of Object.keys(resolved.properties)) { + const prop = resolved.properties[key] if (!prop) continue - if (!itemsField && prop.type === "array" && PAGINATION_ITEMS_FIELDS.includes(key.toLowerCase())) { + const propType = normalizedType(prop) + if (!itemsField && propType === "array" && PAGINATION_ITEMS_FIELDS.includes(key.toLowerCase())) { itemsField = key } - if (!totalField && (prop.type === "integer" || prop.type === "number") && PAGINATION_TOTAL_FIELDS.includes(key.toLowerCase())) { + if (!totalField && (propType === "integer" || propType === "number") && PAGINATION_TOTAL_FIELDS.includes(key.toLowerCase())) { totalField = key } } @@ -54,18 +64,20 @@ export function detectPagination(schema: SchemaObject | null): PaginationConfig } export function inferListItemSchema(route: ParsedRoute): { schema: SchemaObject | null; pagination: PaginationConfig } { - const responseSchema = getResponseSchema(route.responses) - if (!responseSchema) return { schema: null, pagination: { style: "none", itemsField: null, totalField: null } } + const raw = getResponseSchema(route.responses) + if (!raw) return { schema: null, pagination: { style: "none", itemsField: null, totalField: null } } + const responseSchema = resolveEffectiveSchema(raw) - if (responseSchema.type === "array" && responseSchema.items) { - return { schema: responseSchema.items, pagination: { style: "none", itemsField: null, totalField: null } } + if (normalizedType(responseSchema) === "array" && responseSchema.items) { + return { schema: responseSchema.items as SchemaObject, pagination: { style: "none", itemsField: null, totalField: null } } } const pagination = detectPagination(responseSchema) if (pagination.itemsField && responseSchema.properties) { const arrayProp = responseSchema.properties[pagination.itemsField] - if (arrayProp?.items) { - return { schema: arrayProp.items, pagination } + const arrayResolved = arrayProp ? resolveEffectiveSchema(arrayProp) : null + if (arrayResolved?.items) { + return { schema: arrayResolved.items as SchemaObject, pagination } } } diff --git a/src/locales/en.ts b/src/locales/en.ts index a327df6..cabe5f7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -627,6 +627,7 @@ export default { updateFailed: "Update failed: {{status}}", running: "Running...", execute: "Execute", + load: "Load", ok: "Success", requestFailed: "Request failed", tokenApplied: "Authenticated — token applied to the current environment", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 2b5ea44..97b029c 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -627,6 +627,7 @@ export default { updateFailed: "更新失敗:{{status}}", running: "実行中...", execute: "実行", + load: "読み込み", ok: "成功", requestFailed: "リクエストが失敗しました", tokenApplied: "認証済み — トークンが現在の環境に適用されました", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index ad6376a..3819823 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -627,6 +627,7 @@ export default { updateFailed: "업데이트 실패: {{status}}", running: "실행 중...", execute: "실행", + load: "불러오기", ok: "성공", requestFailed: "요청 실패", tokenApplied: "인증됨 — 토큰이 현재 환경에 적용되었습니다", diff --git a/src/locales/zh_CN.ts b/src/locales/zh_CN.ts index a3c2da6..e37221c 100644 --- a/src/locales/zh_CN.ts +++ b/src/locales/zh_CN.ts @@ -628,6 +628,7 @@ export default { updateFailed: "更新失败:{{status}}", running: "执行中...", execute: "执行", + load: "加载", ok: "成功", requestFailed: "请求失败", tokenApplied: "已认证 — Token 已应用到当前环境", diff --git a/src/locales/zh_HK.ts b/src/locales/zh_HK.ts index 1847b6c..e4e58ac 100644 --- a/src/locales/zh_HK.ts +++ b/src/locales/zh_HK.ts @@ -628,6 +628,7 @@ export default { updateFailed: "更新失敗:{{status}}", running: "執行中...", execute: "執行", + load: "載入", ok: "成功", requestFailed: "請求失敗", tokenApplied: "已驗證 — Token 已應用到當前環境", diff --git a/src/locales/zh_TW.ts b/src/locales/zh_TW.ts index 52faf75..2f4523e 100644 --- a/src/locales/zh_TW.ts +++ b/src/locales/zh_TW.ts @@ -628,6 +628,7 @@ export default { updateFailed: "更新失敗:{{status}}", running: "執行中...", execute: "執行", + load: "載入", ok: "成功", requestFailed: "請求失敗", tokenApplied: "已驗證 — Token 已套用至目前環境", From d0953869b24c3b310b60f7154227dd7ec3ddde12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Mon, 15 Jun 2026 12:55:21 +0800 Subject: [PATCH 5/7] fix(openapi): circular-ref examples, generator bounds, diff/diagnostics correctness - parser: mark residual { $ref } nodes (circular:"ignore" leftovers) with _circular, fulfilling the marker contract so display guards become live - generate-example: prune residual/_circular ref nodes before sampling so circular schemas yield a partial example instead of null; clamp inverted exclusive numeric bounds; guard the time-format non-null assertion; wrap generateWithVariant in try/catch and clamp str-alpha length so minLength>30 no longer throws - diff: classify request/response changes by structural path position, not a substring match anywhere in the path - diagnostics: recognize the 2XX range shorthand; skip referenced ({$ref}) responses instead of falsely reporting missing-response-schema; bail scanSchema on ref nodes - tests: circular partial example, variant bounds, inverted exclusive bounds Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/console/schema-inference.test.ts | 2 +- src/lib/openapi/diagnostics.ts | 10 ++- src/lib/openapi/diff.ts | 12 ++-- src/lib/openapi/generate-example.test.ts | 45 ++++++++++++ src/lib/openapi/generate-example.ts | 92 +++++++++++++++--------- src/lib/openapi/parser.ts | 23 ++++++ 6 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 src/lib/openapi/generate-example.test.ts diff --git a/src/lib/console/schema-inference.test.ts b/src/lib/console/schema-inference.test.ts index eb01225..3500ead 100644 --- a/src/lib/console/schema-inference.test.ts +++ b/src/lib/console/schema-inference.test.ts @@ -3,7 +3,7 @@ import { detectPagination, inferListItemSchema } from "@/lib/console/schema-infe import type { ParsedRoute, SchemaObject } from "@/lib/openapi/types" function routeWithResponse(schema: SchemaObject): ParsedRoute { - return { responses: { "200": { content: { "application/json": { schema } } } } } as ParsedRoute + return { responses: { "200": { content: { "application/json": { schema } } } } } as unknown as ParsedRoute } describe("detectPagination (allOf / OAS 3.1)", () => { diff --git a/src/lib/openapi/diagnostics.ts b/src/lib/openapi/diagnostics.ts index d6ede0b..19af9a8 100644 --- a/src/lib/openapi/diagnostics.ts +++ b/src/lib/openapi/diagnostics.ts @@ -106,7 +106,9 @@ function hasEnumExplanation(schema: SchemaObject): boolean { } function shouldCheckResponseSchema(status: string): boolean { - return /^2\d\d$/.test(status) && status !== "204" && status !== "304" + if (status === "204" || status === "304") return false + // Match explicit 2xx codes plus the OpenAPI "2XX" range shorthand. + return /^2\d\d$/.test(status) || /^2xx$/i.test(status) } function responseHasSchema(response: ResponseObject): boolean { @@ -183,6 +185,9 @@ function scanSchema( }, ) { if (!schema) return + // A bare reference node is a pointer, not a schema to inspect; its target is + // checked where it's defined (and redocly handles unresolved refs separately). + if (typeof schema.$ref === "string") return const location = makePointer(path) if (isEmptySchema(schema)) { @@ -283,6 +288,9 @@ function scanOperation( for (const [status, response] of Object.entries(op.responses || {})) { const responsePath = [...operationPath, "responses", status] + // A referenced response ({$ref}) resolves to a component definition; don't + // flag it as missing a schema (it isn't) or scan the pointer as a schema. + if (typeof (response as { $ref?: unknown }).$ref === "string") continue if (shouldCheckResponseSchema(status) && !responseHasSchema(response)) { addIssue(issues, { code: "missing-response-schema", diff --git a/src/lib/openapi/diff.ts b/src/lib/openapi/diff.ts index df4c61e..ecaeb9f 100644 --- a/src/lib/openapi/diff.ts +++ b/src/lib/openapi/diff.ts @@ -58,15 +58,19 @@ function getKind(diff: Diff): OpenAPIDiffKind | null { if (root !== "paths") return null const method = getPathPart(diff, 2) - if (method && HTTP_METHODS.has(method.toLowerCase()) && diff.path.length === 3) { + const isOperation = !!method && HTTP_METHODS.has(method.toLowerCase()) + if (isOperation && diff.path.length === 3) { return diff.action === "add" ? "endpoint-added" : diff.action === "remove" ? "endpoint-removed" : null } - if (diff.path.includes("requestBody") || diff.path.includes("parameters")) { + // Classify by the structural field, not a substring match anywhere in the path + // (a schema property literally named "responses" must not be misclassified). + // Operation-level fields sit at index 3; path-item-level parameters at index 2. + const field = isOperation ? getPathPart(diff, 3) : getPathPart(diff, 2) + if (field === "requestBody" || field === "parameters") { return "request-schema-changed" } - - if (diff.path.includes("responses")) { + if (field === "responses") { return "response-schema-changed" } diff --git a/src/lib/openapi/generate-example.test.ts b/src/lib/openapi/generate-example.test.ts new file mode 100644 index 0000000..c64f07c --- /dev/null +++ b/src/lib/openapi/generate-example.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" +import { generateExample, generateWithVariant } from "@/lib/openapi/generate-example" +import type { SchemaObject } from "@/lib/openapi/types" + +describe("generateExample circular handling", () => { + it("produces a partial example (not null) when a residual circular $ref exists", () => { + const schema: SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + self: { $ref: "#/components/schemas/Node" }, // residual circular ref + }, + } + const ex = generateExample(schema) as Record | null + expect(ex).not.toBeNull() + expect(ex).toHaveProperty("name") + }) + + it("handles _circular-marked nodes without bailing to null", () => { + const schema: SchemaObject = { + type: "object", + properties: { + id: { type: "integer" }, + ref: { _circular: "#/components/schemas/Node" }, + }, + } + expect(generateExample(schema)).not.toBeNull() + }) +}) + +describe("generateWithVariant boundaries", () => { + it("does not throw and returns a string when minLength > 30 (str-alpha)", () => { + const schema: SchemaObject = { type: "string", minLength: 50 } + expect(() => generateWithVariant(schema, "str-alpha")).not.toThrow() + expect(typeof generateWithVariant(schema, "str-alpha")).toBe("string") + }) +}) + +describe("generateExample numeric bounds", () => { + it("does not throw on inverted exclusive integer bounds", () => { + const schema: SchemaObject = { type: "object", properties: { n: { type: "integer", exclusiveMinimum: 10, exclusiveMaximum: 10 } } } + expect(() => generateExample(schema)).not.toThrow() + expect(generateExample(schema)).not.toBeNull() + }) +}) diff --git a/src/lib/openapi/generate-example.ts b/src/lib/openapi/generate-example.ts index 8248fee..c263ec0 100644 --- a/src/lib/openapi/generate-example.ts +++ b/src/lib/openapi/generate-example.ts @@ -52,6 +52,7 @@ function fakerForSchema(schema: SchemaObject): unknown { else if (schema.exclusiveMinimum === true && schema.minimum !== undefined) min = Number(schema.minimum) + 1 if (typeof schema.exclusiveMaximum === "number") max = schema.exclusiveMaximum - 1 else if (schema.exclusiveMaximum === true && schema.maximum !== undefined) max = Number(schema.maximum) - 1 + if (min > max) max = min // exclusive bounds can invert the range return faker.number.int({ min, max }) } @@ -63,6 +64,7 @@ function fakerForSchema(schema: SchemaObject): unknown { else if (schema.exclusiveMinimum === true && schema.minimum !== undefined) min = Number(schema.minimum) + 0.01 if (typeof schema.exclusiveMaximum === "number") max = schema.exclusiveMaximum - 0.01 else if (schema.exclusiveMaximum === true && schema.maximum !== undefined) max = Number(schema.maximum) - 0.01 + if (min > max) max = min // exclusive bounds can invert the range return faker.number.float({ min, max, fractionDigits: 2 }) } @@ -89,7 +91,10 @@ function fakerForSchema(schema: SchemaObject): unknown { switch (fmt) { case "date-time": return faker.date.recent().toISOString() case "date": return faker.date.recent().toISOString().split("T")[0] - case "time": return faker.date.recent().toISOString().split("T")[1]!.replace("Z", "+00:00") + case "time": { + const timePart = faker.date.recent().toISOString().split("T")[1] + return timePart ? timePart.replace("Z", "+00:00") : "00:00:00+00:00" + } case "duration": return `P${faker.number.int({ min: 1, max: 30 })}D` case "email": case "idn-email": return faker.internet.email() @@ -228,45 +233,68 @@ export function generateWithVariant(rawSchema: SchemaObject, variantId: string): const schema = resolveEffectiveSchema(rawSchema) const minLen = schema.minLength ?? 1 const maxLen = schema.maxLength ?? Math.max(minLen, 20) + // Clamp so the random length is always >= min (minLength > 30 would otherwise + // make faker.number.int receive min > max and throw). + const alphaCap = Math.max(minLen, Math.min(maxLen, 30)) + const sliceCap = Math.max(maxLen, minLen) - switch (variantId) { - // Phone - case "phone-international": return faker.phone.number({ style: "international" }) - case "phone-e164": return randomE164() - case "phone-digits": return generateNationalForCountry("CN") - case "phone-cn": { - const n = faker.string.numeric(11) - return `${n.slice(0, 3)}-${n.slice(3, 7)}-${n.slice(7)}` + try { + switch (variantId) { + // Phone + case "phone-international": return faker.phone.number({ style: "international" }) + case "phone-e164": return randomE164() + case "phone-digits": return generateNationalForCountry("CN") + case "phone-cn": { + const n = faker.string.numeric(11) + return `${n.slice(0, 3)}-${n.slice(3, 7)}-${n.slice(7)}` + } + // DateTime + case "dt-recent": return faker.date.recent().toISOString() + case "dt-past": return faker.date.past().toISOString() + case "dt-future": return faker.date.future().toISOString() + case "dt-epoch": return String(Math.floor(faker.date.recent().getTime() / 1000)) + // Date + case "date-recent": return faker.date.recent().toISOString().split("T")[0] + case "date-past": return faker.date.past().toISOString().split("T")[0] + case "date-future": return faker.date.future().toISOString().split("T")[0] + // Email + case "email-random": return faker.internet.email() + case "email-example": return `user${faker.number.int({ min: 1, max: 999 })}@example.com` + // UUID + case "uuid-v4": return faker.string.uuid() + case "uuid-nil": return "00000000-0000-0000-0000-000000000000" + // String + case "str-alpha": return faker.string.alphanumeric(faker.number.int({ min: minLen, max: alphaCap })) + case "str-lorem": return faker.lorem.words(faker.number.int({ min: 1, max: 5 })).slice(0, sliceCap) + case "str-slug": return faker.lorem.slug(faker.number.int({ min: 1, max: 4 })).slice(0, sliceCap) + default: return generateExample(rawSchema) } - // DateTime - case "dt-recent": return faker.date.recent().toISOString() - case "dt-past": return faker.date.past().toISOString() - case "dt-future": return faker.date.future().toISOString() - case "dt-epoch": return String(Math.floor(faker.date.recent().getTime() / 1000)) - // Date - case "date-recent": return faker.date.recent().toISOString().split("T")[0] - case "date-past": return faker.date.past().toISOString().split("T")[0] - case "date-future": return faker.date.future().toISOString().split("T")[0] - // Email - case "email-random": return faker.internet.email() - case "email-example": return `user${faker.number.int({ min: 1, max: 999 })}@example.com` - // UUID - case "uuid-v4": return faker.string.uuid() - case "uuid-nil": return "00000000-0000-0000-0000-000000000000" - // String - case "str-alpha": return faker.string.alphanumeric(faker.number.int({ min: minLen, max: Math.min(maxLen, 30) })) - case "str-lorem": return faker.lorem.words(faker.number.int({ min: 1, max: 5 })).slice(0, maxLen) - case "str-slug": return faker.lorem.slug(faker.number.int({ min: 1, max: 4 })).slice(0, maxLen) - default: return generateExample(rawSchema) + } catch { + return generateExample(rawSchema) } } +// Replace residual $ref nodes (left by the parser's circular:"ignore") and any +// _circular/_unresolved-marked nodes with an empty schema, so openapi-sampler can +// still produce a partial example instead of throwing on the first $ref. +function pruneCircularRefs(schema: unknown, seen: WeakSet = new WeakSet()): unknown { + if (!schema || typeof schema !== "object") return schema + if (Array.isArray(schema)) return schema.map(s => pruneCircularRefs(s, seen)) + const obj = schema as Record + if (typeof obj.$ref === "string" || obj._circular || obj._unresolved) return {} + if (seen.has(schema)) return {} + seen.add(schema) + const out: Record = {} + for (const [k, v] of Object.entries(obj)) out[k] = pruneCircularRefs(v, seen) + return out +} + export function generateExample(schema: SchemaObject | null | undefined): unknown { if (!schema) return null - if (schema._circular || schema._unresolved) return null try { - const base = sample(schema as Record) - return randomizeLeaves(base, schema) + const pruned = pruneCircularRefs(schema) as SchemaObject + const base = sample(pruned as Record) + return randomizeLeaves(base, pruned) } catch { return null } diff --git a/src/lib/openapi/parser.ts b/src/lib/openapi/parser.ts index 8df725f..52b865c 100644 --- a/src/lib/openapi/parser.ts +++ b/src/lib/openapi/parser.ts @@ -129,6 +129,28 @@ export function sanitizeNonStandardExtensions(spec: OpenAPISpec): { spec: OpenAP return { spec: result, warnings } } +// After dereferencing with circular:"ignore", circular references remain as +// literal { $ref } nodes. Tag them with _circular so format-schema / SchemaTree +// can render "[circular]" and example generation can prune them (the marker the +// types.ts comment always promised but nothing ever set). +function markCircularRefs(root: unknown): void { + const seen = new WeakSet() + const walk = (node: unknown): void => { + if (!node || typeof node !== "object" || seen.has(node)) return + seen.add(node) + if (Array.isArray(node)) { + node.forEach(walk) + return + } + const obj = node as Record + if (typeof obj.$ref === "string" && obj._circular === undefined) { + obj._circular = obj.$ref + } + for (const v of Object.values(obj)) walk(v) + } + walk(root) +} + export async function parseValidatedSpec( input: string | OpenAPISpec, opts: { sourceUrl?: string } = {}, @@ -154,6 +176,7 @@ export async function parseValidatedSpec( const dereferenceInput = asParserInput(cloneSpec(sanitizedSource)) const spec = await dereference(dereferenceInput, parserOptions) + markCircularRefs(spec) if (blocked.size > 0) { const sample = [...blocked].slice(0, 5).join(", ") From 1b975cb181e2e9faa64fccacab8591930ff85e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Mon, 15 Jun 2026 13:02:38 +0800 Subject: [PATCH 6/7] fix(types): honest exclusive bounds, reference objects, typeStr, FormData, casts - types: exclusiveMinimum/Maximum is number|boolean (OAS 3.0 boolean vs 3.1 number); Response/RequestBody/Parameter carry an optional $ref so reference nodes in a source (non-dereferenced) spec are type-honest - type-str: render object enum values via JSON.stringify (no [object Object]); handle anyOf/oneOf nullable unions incl. OAS 3.1 {type:["null"]}; render OAS 3.0 boolean exclusive bounds as strict instead of "true" - diagnostics: drop the response $ref cast now that ResponseObject types it - use-request: encode urlencoded bodies honestly (string fields only) instead of casting FormData to Record - use-auth: type the token response instead of reading .access_token off any - AppSidebar: drop 8 redundant `as MainView` casts (let the union check literals) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/layout/AppSidebar.tsx | 17 ++++++++--------- src/hooks/use-auth.ts | 2 +- src/hooks/use-request.ts | 26 +++++++++++++++----------- src/lib/openapi/diagnostics.ts | 2 +- src/lib/openapi/type-str.ts | 28 ++++++++++++++++++---------- src/lib/openapi/types.ts | 11 +++++++++-- 6 files changed, 52 insertions(+), 34 deletions(-) diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index e4897b7..ebab274 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -23,7 +23,6 @@ import { useAsyncAPIContext } from "@/contexts/AsyncAPIContext" import { useConsoleContext } from "@/contexts/ConsoleContext" import { ConsoleImportExport } from "@/components/console/ConsoleImportExport" import { useFavorites } from "@/hooks/use-favorites" -import type { MainView } from "@/lib/openapi/types" import { APP_VERSION, GITHUB_URL, getBuildLabel } from "@/lib/app-info" import { WorkspaceSwitcher } from "@/components/layout/WorkspaceSwitcher" import { EnvironmentSwitcher } from "@/components/layout/EnvironmentSwitcher" @@ -44,12 +43,12 @@ export function AppSidebar() { const hasConsoleResources = groups.some(g => g.resources.length > 0) const handleConsoleResource = (basePath: string) => { - setMainView("console" as MainView) + setMainView("console") consoleDispatch({ type: "SET_ACTIVE_RESOURCE", key: basePath }) } const handleConsoleAction = (basePath: string, actionIndex: number) => { - setMainView("console" as MainView) + setMainView("console") consoleDispatch({ type: "SET_ACTIVE_ACTION", key: basePath, actionIndex }) } @@ -67,7 +66,7 @@ export function AppSidebar() { setMainView("channels" as MainView)} + onClick={() => setMainView("channels")} > {t("sidebar.channels")} @@ -81,7 +80,7 @@ export function AppSidebar() { setMainView("endpoints" as MainView)} + onClick={() => setMainView("endpoints")} > {t("sidebar.endpoints")} @@ -97,7 +96,7 @@ export function AppSidebar() { setMainView("favorites" as MainView)} + onClick={() => setMainView("favorites")} > {t("sidebar.favorites")} @@ -112,7 +111,7 @@ export function AppSidebar() { setMainView("models" as MainView)} + onClick={() => setMainView("models")} > {t("sidebar.models")} @@ -128,7 +127,7 @@ export function AppSidebar() { setMainView("diagnostics" as MainView)} + onClick={() => setMainView("diagnostics")} > {t("sidebar.diagnostics")} @@ -137,7 +136,7 @@ export function AppSidebar() { setMainView("diff" as MainView)} + onClick={() => setMainView("diff")} > {t("sidebar.diff")} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index f004613..0e06eef 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -70,7 +70,7 @@ export function useAuth() { } throw new Error(`${res.status} ${detail}`.substring(0, 80)) } - const data = await res.json() + const data = await res.json() as { access_token?: string } const token = data.access_token if (!token) throw new Error(i18n.t("validation.noAccessToken")) setOAuth2Token(token) diff --git a/src/hooks/use-request.ts b/src/hooks/use-request.ts index bc38c9c..0ed5c03 100644 --- a/src/hooks/use-request.ts +++ b/src/hooks/use-request.ts @@ -154,20 +154,24 @@ export function useRequest(getAuthHeaders: () => Record) { if (route.requestBody) { if (contentType === "multipart/form-data" || contentType === "application/x-www-form-urlencoded") { - const form = new FormData() - if (formData) { - for (const [name, val] of Object.entries(formData)) { - if (val instanceof File) { - form.append(name, val) - } else if (val) { - form.append(name, val) - } - } - } if (contentType === "application/x-www-form-urlencoded") { + // urlencoded can't carry files; encode only string fields honestly. headers["Content-Type"] = "application/x-www-form-urlencoded" - fetchBody = new URLSearchParams(form as unknown as Record).toString() + const usp = new URLSearchParams() + if (formData) { + for (const [name, val] of Object.entries(formData)) { + if (typeof val === "string" && val) usp.append(name, val) + } + } + fetchBody = usp.toString() } else { + const form = new FormData() + if (formData) { + for (const [name, val] of Object.entries(formData)) { + if (val instanceof File) form.append(name, val) + else if (val) form.append(name, val) + } + } fetchBody = form } } else if (body.trim()) { diff --git a/src/lib/openapi/diagnostics.ts b/src/lib/openapi/diagnostics.ts index 19af9a8..1d7df09 100644 --- a/src/lib/openapi/diagnostics.ts +++ b/src/lib/openapi/diagnostics.ts @@ -290,7 +290,7 @@ function scanOperation( const responsePath = [...operationPath, "responses", status] // A referenced response ({$ref}) resolves to a component definition; don't // flag it as missing a schema (it isn't) or scan the pointer as a schema. - if (typeof (response as { $ref?: unknown }).$ref === "string") continue + if (typeof response.$ref === "string") continue if (shouldCheckResponseSchema(status) && !responseHasSchema(response)) { addIssue(issues, { code: "missing-response-schema", diff --git a/src/lib/openapi/type-str.ts b/src/lib/openapi/type-str.ts index dbcddbe..0016e7c 100644 --- a/src/lib/openapi/type-str.ts +++ b/src/lib/openapi/type-str.ts @@ -12,15 +12,21 @@ export function getTypeStr(schema: SchemaObject | undefined): string { } if (schema.format) t += `(${schema.format})`; if (schema.const !== undefined) t += ` const: ${JSON.stringify(schema.const)}`; - if (schema.enum) t += ` enum: [${schema.enum.join(', ')}]`; + if (schema.enum) { + const rendered = schema.enum.map((v: unknown) => (v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v))); + t += ` enum: [${rendered.join(', ')}]`; + } if (schema.default !== undefined) t += ` default: ${JSON.stringify(schema.default)}`; // OAS 3.0 nullable / Swagger 2.0 x-nullable if (schema.nullable || schema['x-nullable']) t += ' | null'; - if (schema.anyOf) { - const types = schema.anyOf.map(s => s.type).filter(Boolean); - if (types.includes('null')) { - const other = schema.anyOf.find(s => s.type !== 'null'); - if (other) return getTypeStr(other) + ' | null'; + // anyOf/oneOf nullable union: [SomeType, {type:"null"}] (also OAS 3.1 {type:["null"]}) + for (const key of ['anyOf', 'oneOf'] as const) { + const variants = schema[key]; + if (!variants) continue; + const isNull = (s: SchemaObject) => s.type === 'null' || (Array.isArray(s.type) && s.type.length === 1 && s.type[0] === 'null'); + const nonNull = variants.filter(s => !isNull(s)); + if (variants.some(isNull) && nonNull.length === 1) { + return getTypeStr(nonNull[0]) + ' | null'; } } return t; @@ -30,10 +36,12 @@ export function getConstraints(prop: SchemaObject): string { const p: string[] = []; if (prop.maxLength !== undefined) p.push(`maxLen: ${prop.maxLength}`); if (prop.minLength !== undefined) p.push(`minLen: ${prop.minLength}`); - if (prop.maximum !== undefined) p.push(`max: ${prop.maximum}`); - if (prop.minimum !== undefined) p.push(`min: ${prop.minimum}`); - if (prop.exclusiveMaximum !== undefined) p.push(`exclusiveMax: ${prop.exclusiveMaximum}`); - if (prop.exclusiveMinimum !== undefined) p.push(`exclusiveMin: ${prop.exclusiveMinimum}`); + // exclusiveMax/Min is a number in OAS 3.1 but a boolean flag on minimum/maximum + // in OAS 3.0; render the boolean form as a strict bound rather than "true". + if (prop.maximum !== undefined) p.push(prop.exclusiveMaximum === true ? `max: <${prop.maximum}` : `max: ${prop.maximum}`); + if (prop.minimum !== undefined) p.push(prop.exclusiveMinimum === true ? `min: >${prop.minimum}` : `min: ${prop.minimum}`); + if (typeof prop.exclusiveMaximum === "number") p.push(`exclusiveMax: ${prop.exclusiveMaximum}`); + if (typeof prop.exclusiveMinimum === "number") p.push(`exclusiveMin: ${prop.exclusiveMinimum}`); if (prop.pattern) p.push(`pattern: ${prop.pattern}`); if (prop.maxItems !== undefined) p.push(`maxItems: ${prop.maxItems}`); if (prop.minItems !== undefined) p.push(`minItems: ${prop.minItems}`); diff --git a/src/lib/openapi/types.ts b/src/lib/openapi/types.ts index 9bbb54d..8ec8f45 100644 --- a/src/lib/openapi/types.ts +++ b/src/lib/openapi/types.ts @@ -87,12 +87,16 @@ export interface Parameter { format?: string enum?: unknown[] default?: unknown + /** Present when this object is a Reference Object ({$ref}) in a non-dereferenced (source) spec. */ + $ref?: string } export interface RequestBody { required?: boolean description?: string content?: Record + /** Present when this object is a Reference Object ({$ref}) in a non-dereferenced (source) spec. */ + $ref?: string } export interface MediaTypeObject { @@ -125,8 +129,9 @@ export interface SchemaObject extends Record { additionalProperties?: boolean | SchemaObject minimum?: number maximum?: number - exclusiveMinimum?: number - exclusiveMaximum?: number + // boolean in OAS 3.0 / Swagger 2.0 (paired with minimum/maximum), number in OAS 3.1 + exclusiveMinimum?: number | boolean + exclusiveMaximum?: number | boolean multipleOf?: number minLength?: number maxLength?: number @@ -144,6 +149,8 @@ export interface ResponseObject { description?: string content?: Record schema?: SchemaObject + /** Present when this object is a Reference Object ({$ref}) in a non-dereferenced (source) spec. */ + $ref?: string } export interface OAuthFlowObject { From 65c56d321cf61187409884a8efaf00285aec96ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Mon, 15 Jun 2026 13:16:57 +0800 Subject: [PATCH 7/7] fix(perf,db): memoize context values, atomic deleteSpec, db perf, optimistic rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAPIContext / useEnvironments / useMultiEnvStatus: memoize the provider value so consumers don't re-render on every provider render - db.deleteSpec: cascade in a single atomic transaction (all-or-nothing) so a mid-way failure can't orphan records — notably token-bearing credentials - db.putSpecFromDocument: warn when a same-id spec has different content (history/ favorites/credentials may not align); primary key unchanged, no migration - db.clearWsHistory: bound the cursor to specId (and channel) instead of full scan - db.getEnvironmentRuntimes: read credentials in one transaction, not per-profile - use-favorites: optimistic toggle now uses functional updates and rolls back on persistence failure - JsonSchemaTreeView: memoize flattenTree result and React.memo the row - test mock updated for multi-store transactions Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/schema/JsonSchemaTreeView.tsx | 7 +- src/contexts/OpenAPIContext.tsx | 14 ++- src/hooks/use-environments.ts | 6 +- src/hooks/use-favorites.ts | 20 ++-- src/hooks/use-multi-env-status.ts | 4 +- src/lib/db-specs.test.ts | 18 +++- src/lib/db.ts | 102 ++++++++++++------- 7 files changed, 113 insertions(+), 58 deletions(-) diff --git a/src/components/schema/JsonSchemaTreeView.tsx b/src/components/schema/JsonSchemaTreeView.tsx index 3535ee1..33bd50f 100644 --- a/src/components/schema/JsonSchemaTreeView.tsx +++ b/src/components/schema/JsonSchemaTreeView.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo } from "react" import { useTranslation } from "react-i18next" import { Badge, badgeVariants } from "@/components/ui/badge" import { @@ -119,7 +120,7 @@ function NodeMeta({ node }: { node: JsonSchemaTreeNode }) { ) } -function JsonSchemaTreeRow({ +const JsonSchemaTreeRow = memo(function JsonSchemaTreeRow({ node, depth, hasDetails, @@ -189,7 +190,7 @@ function JsonSchemaTreeRow({ ) -} +}) export function JsonSchemaTreeView({ nodes, @@ -199,7 +200,7 @@ export function JsonSchemaTreeView({ selectedNodeId, }: JsonSchemaTreeViewProps) { const { t } = useTranslation() - const rows = flattenTree(nodes) + const rows = useMemo(() => flattenTree(nodes), [nodes]) if (nodes.length === 0) { return ( diff --git a/src/contexts/OpenAPIContext.tsx b/src/contexts/OpenAPIContext.tsx index e09a1f8..0c0ef9e 100644 --- a/src/contexts/OpenAPIContext.tsx +++ b/src/contexts/OpenAPIContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useReducer, useCallback } from "react" +import { createContext, useContext, useReducer, useCallback, useMemo } from "react" import type { ReactNode } from "react" import type { OpenAPISpec, @@ -437,7 +437,9 @@ export function OpenAPIProvider({ children }: { children: ReactNode }) { dispatch({ type: "SET_SPEC_URL", url }) }, []) - const value: OpenAPIContextValue = { + // Memoized so consumers don't re-render on every provider render; all the + // setters are stable useCallbacks, so this effectively changes only with state. + const value: OpenAPIContextValue = useMemo(() => ({ state, dispatch, toggleRoute, @@ -465,7 +467,13 @@ export function OpenAPIProvider({ children }: { children: ReactNode }) { setMainView, setBaseUrl, setSpecUrl, - } + }), [ + state, toggleRoute, selectRoutes, deselectRoutes, selectAllRoutes, clearRouteSelection, + toggleModel, selectAllModels, clearModelSelection, toggleTag, clearTags, invertTags, + setFilter, setActiveEndpointKey, setEndpointDetailTab, setModelFilter, setModelViewMode, + setActiveModelName, setSchemaFilter, setSchemaCategoryFilter, setSchemaTypeFilter, + setActiveSchemaName, setSchemaSource, setMainView, setBaseUrl, setSpecUrl, + ]) return ( diff --git a/src/hooks/use-environments.ts b/src/hooks/use-environments.ts index 88a26f1..43b730f 100644 --- a/src/hooks/use-environments.ts +++ b/src/hooks/use-environments.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, createContext, useContext } from "react" +import { useState, useEffect, useCallback, useRef, useMemo, createContext, useContext } from "react" import { useSpecId } from "@/hooks/use-spec-id" import { useOpenAPIContext } from "@/contexts/OpenAPIContext" import { useAuthContext } from "@/contexts/AuthContext" @@ -312,7 +312,7 @@ export function useEnvironmentsProvider(): EnvironmentsContextValue { const activeEnv = environments.find(e => e.id === activeEnvId) || null - return { + return useMemo(() => ({ environments, activeEnvId, activeEnv, @@ -321,7 +321,7 @@ export function useEnvironmentsProvider(): EnvironmentsContextValue { addEnvironment, updateEnvironment, removeEnvironment: removeEnvironmentFn, - } + }), [environments, activeEnvId, activeEnv, loading, switchEnvironment, addEnvironment, updateEnvironment, removeEnvironmentFn]) } export function useEnvironments() { diff --git a/src/hooks/use-favorites.ts b/src/hooks/use-favorites.ts index a28b5a1..853cf19 100644 --- a/src/hooks/use-favorites.ts +++ b/src/hooks/use-favorites.ts @@ -36,15 +36,17 @@ export function useFavoritesProvider(): FavoritesContextValue { const toggleFavorite = useCallback((routeKey: string) => { if (!specId) return - const next = new Set(effectiveFavorites) - if (next.has(routeKey)) { - next.delete(routeKey) - removeFavorite(specId, routeKey) - } else { - next.add(routeKey) - addFavorite(specId, routeKey) - } - setFavorites(next) + const wasFavorite = effectiveFavorites.has(routeKey) + // Optimistic, functional update (avoids stale-snapshot overwrites on rapid toggles). + const apply = (add: boolean) => setFavorites(prev => { + const next = new Set(prev) + if (add) next.add(routeKey) + else next.delete(routeKey) + return next + }) + apply(!wasFavorite) + const op = wasFavorite ? removeFavorite(specId, routeKey) : addFavorite(specId, routeKey) + op.catch(() => apply(wasFavorite)) // roll back on persistence failure }, [specId, effectiveFavorites]) return { favorites: effectiveFavorites, isFavorite, toggleFavorite } diff --git a/src/hooks/use-multi-env-status.ts b/src/hooks/use-multi-env-status.ts index a319c59..aac46b6 100644 --- a/src/hooks/use-multi-env-status.ts +++ b/src/hooks/use-multi-env-status.ts @@ -255,14 +255,14 @@ export function useMultiEnvStatusProvider(): MultiEnvStatusValue { return null }, [envStatuses]) - return { + return useMemo(() => ({ envStatuses, getRoutePresence, inferStatus, loading, refresh, enabled, - } + }), [envStatuses, getRoutePresence, inferStatus, loading, refresh, enabled]) } export function useMultiEnvStatus() { diff --git a/src/lib/db-specs.test.ts b/src/lib/db-specs.test.ts index 516d23d..535fbe0 100644 --- a/src/lib/db-specs.test.ts +++ b/src/lib/db-specs.test.ts @@ -92,6 +92,13 @@ class FakeIndex { }) return Promise.resolve(entries.length > 0 ? new FakeCursor(entries, 0, this.records) : null) } + + getAll(query?: IDBValidKey): Promise { + return Promise.resolve([...this.records.values()].filter(record => { + if (this.indexName === "specId") return hasSpecId(record) && record.specId === query + return true + })) + } } class FakeStore { @@ -105,6 +112,11 @@ class FakeStore { const entries = [...this.records.entries()] return Promise.resolve(entries.length > 0 ? new FakeCursor(entries, 0, this.records) : null) } + + delete(key: IDBValidKey): Promise { + this.records.delete(key) + return Promise.resolve() + } } class FakeDB { @@ -150,9 +162,11 @@ class FakeDB { return Promise.resolve() } - transaction(storeName: string): { store: FakeStore; done: Promise } { + transaction(storeName: string | string[]): { store: FakeStore; objectStore: (name: string) => FakeStore; done: Promise } { + const first = Array.isArray(storeName) ? storeName[0]! : storeName return { - store: new FakeStore(this.stores[toStoreName(storeName)]), + store: new FakeStore(this.stores[toStoreName(first)]), + objectStore: (name: string) => new FakeStore(this.stores[toStoreName(name)]), done: Promise.resolve(), } } diff --git a/src/lib/db.ts b/src/lib/db.ts index 8f32d45..bca6cf9 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -501,6 +501,16 @@ export async function putSpecFromDocument( const db = await getDB() const id = computeSpecId(spec, specUrl) const existing = await db.get("specs", id) as SpecRecord | undefined + // computeSpecId only hashes title@version::origin. If a different document + // collides on that id (different contentHash), its history/favorites/credentials + // were keyed for the old content and may not match — surface it instead of + // silently overwriting. (Primary key intentionally unchanged; no DB migration.) + if (existing && existing.contentHash !== hashSpec(spec)) { + console.warn( + `[apilot] spec "${id}" now has different content than the stored record; ` + + `existing history/favorites/credentials may not align with the new document.`, + ) + } const record = createSpecRecord(spec, specUrl, sourceType, existing ?? null) await db.put("specs", record) return record @@ -527,45 +537,50 @@ export async function touchSpec(specId: string): Promise { await db.put("specs", { ...spec, lastOpenedAt: now, updatedAt: now }) } -async function deleteAllFromIndex(storeName: string, indexName: string, query: IDBValidKey | IDBKeyRange): Promise { - const db = await getDB() - const tx = db.transaction(storeName, "readwrite") - const index = tx.store.index(indexName) - let cursor = await index.openCursor(query) +async function clearByIndex( + store: IDBPObjectStore, + indexName: string, + key: IDBValidKey, +): Promise { + let cursor = await store.index(indexName).openCursor(key) while (cursor) { cursor.delete() cursor = await cursor.continue() } - await tx.done -} - -async function deleteWsHistoryForSpec(specId: string): Promise { - const db = await getDB() - const tx = db.transaction("wsHistory", "readwrite") - let cursor = await tx.store.openCursor() - while (cursor) { - const record = cursor.value as WsHistoryEntry - if (record.specId === specId) cursor.delete() - cursor = await cursor.continue() - } - await tx.done } export async function deleteSpec(specId: string): Promise { const db = await getDB() - const environments = await db.getAllFromIndex("environments", "specId", specId) as EnvironmentProfile[] - - await Promise.all(environments.map(env => db.delete("environmentCredentials", env.id))) - await Promise.all([ - deleteAllFromIndex("environments", "specId", specId), - deleteAllFromIndex("envVars", "specId", specId), - deleteAllFromIndex("favorites", "specId", specId), - deleteAllFromIndex("history", "specId", specId), - deleteWsHistoryForSpec(specId), - deleteAllFromIndex("consoleLayouts", "specId", specId), - db.delete("specSettings", specId), - db.delete("specs", specId), - ]) + // Single atomic transaction across every related store: cascade either fully + // commits or fully rolls back, so a mid-way failure can't leave orphaned records + // (notably orphaned environmentCredentials, which hold tokens). + const tx = db.transaction( + ["environments", "environmentCredentials", "envVars", "favorites", "history", "wsHistory", "consoleLayouts", "specSettings", "specs"], + "readwrite", + ) + + // Credentials are keyed by envId, so resolve this spec's environments first. + const envs = await tx.objectStore("environments").index("specId").getAll(specId) as EnvironmentProfile[] + const credStore = tx.objectStore("environmentCredentials") + for (const env of envs) await credStore.delete(env.id) + + await clearByIndex(tx.objectStore("environments"), "specId", specId) + await clearByIndex(tx.objectStore("envVars"), "specId", specId) + await clearByIndex(tx.objectStore("favorites"), "specId", specId) + await clearByIndex(tx.objectStore("history"), "specId", specId) + await clearByIndex(tx.objectStore("consoleLayouts"), "specId", specId) + + // wsHistory has no specId-only index — scan and match by specId. + const wsStore = tx.objectStore("wsHistory") + let wsCursor = await wsStore.openCursor() + while (wsCursor) { + if ((wsCursor.value as WsHistoryEntry).specId === specId) wsCursor.delete() + wsCursor = await wsCursor.continue() + } + + await tx.objectStore("specSettings").delete(specId) + await tx.objectStore("specs").delete(specId) + await tx.done } export async function getSpecSettings(specId: string): Promise { @@ -857,8 +872,19 @@ export async function getEnvironmentCredential(envId: string): Promise { - const profiles = await getEnvironments(specId) - const credentials = await Promise.all(profiles.map(profile => getEnvironmentCredential(profile.id))) + const db = await getDB() + const profiles = await db.getAllFromIndex("environments", "specId", specId) as EnvironmentProfile[] + if (profiles.length === 0) return [] + // Read all credentials within a single transaction instead of opening one DB + // connection per profile. + const tx = db.transaction("environmentCredentials", "readonly") + const store = tx.objectStore("environmentCredentials") + const credentials = await Promise.all( + profiles.map(async profile => + (await store.get(profile.id) as EnvironmentCredential | undefined) ?? createEmptyEnvironmentCredential(profile.id), + ), + ) + await tx.done return profiles.map((profile, index) => ({ ...profile, ...credentials[index]! })) } @@ -906,12 +932,16 @@ export async function clearWsHistory(specId: string, channelId?: string, envId?: const db = await getDB() const tx = db.transaction("wsHistory", "readwrite") const index = tx.store.index("specId_channelId") - let cursor = await index.openCursor() + // Narrow the cursor to this spec (and channel, if given) instead of scanning the + // whole store. Array sorts after string, so [specId, []] bounds all channels. + const range = channelId + ? IDBKeyRange.only([specId, channelId]) + : IDBKeyRange.bound([specId], [specId, []]) + let cursor = await index.openCursor(range) while (cursor) { const record = cursor.value as WsHistoryEntry - const channelMatches = !channelId || record.channelId === channelId const envMatches = envId === undefined || (record.envId ?? null) === envId - if (record.specId === specId && channelMatches && envMatches) cursor.delete() + if (envMatches) cursor.delete() cursor = await cursor.continue() } await tx.done