From 9123b7acc97dcc6002c23c96387e0b437ff5aba8 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 14:28:27 +0800 Subject: [PATCH 1/5] fix(review): address PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use-settings: refuse auto-loading a query-string openapi_url pointing at an internal/loopback host (SSRF via share link); user-typed/legacy URLs exempt - parser: cap external $ref response bodies via readResponseTextCapped too - vite PWA: don't cache same-origin specs (may be cookie/session authenticated) — only cross-origin public specs - ConsoleFormDialog/DetailCard: pass loaded path params as id fallback when the response body doesn't echo the id - generate-example: pruneCircularRefs tracks the ancestor path (not a global set) so shared sibling schemas aren't emptied as false cycles - tests: shared-schema reuse Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/console/ConsoleFormDialog.tsx | 13 +++++++++---- .../console/templates/DetailCardTemplate.tsx | 2 +- src/hooks/use-settings.ts | 19 ++++++++++++++++--- src/lib/openapi/generate-example.test.ts | 10 ++++++++++ src/lib/openapi/generate-example.ts | 13 ++++++++----- src/lib/openapi/parser.ts | 5 ++++- vite.config.ts | 13 ++++++++----- 7 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/components/console/ConsoleFormDialog.tsx b/src/components/console/ConsoleFormDialog.tsx index 442bb1b..0ec1746 100644 --- a/src/components/console/ConsoleFormDialog.tsx +++ b/src/components/console/ConsoleFormDialog.tsx @@ -16,10 +16,13 @@ interface Props { resource: ConsoleResource mode: "create" | "edit" initialData?: Record | undefined + /** Path params (e.g. the id the detail page was loaded with) — fallback when the + * response body doesn't echo the id field. */ + pathParams?: Record | undefined onSuccess: () => void } -export function ConsoleFormDialog({ resource, mode, initialData, onSuccess }: Props) { +export function ConsoleFormDialog({ resource, mode, initialData, pathParams, onSuccess }: Props) { const { t } = useTranslation() const { dispatch, activeLayout } = useConsoleContext() const auth = useAuthContext() @@ -41,10 +44,12 @@ export function ConsoleFormDialog({ resource, mode, initialData, onSuccess }: Pr const handleSubmit = async () => { if (!operation) return const body = JSON.stringify(formData) - const params: Record = {} + // Seed with the path params the page was loaded with; the response's own id + // (if present) takes precedence as it's the authoritative record id. + const params: Record = { ...(pathParams ?? {}) } - if (mode === "edit" && resource.idParam && initialData) { - const id = String(initialData[resource.idParam] ?? initialData["id"] ?? "") + if (mode === "edit" && resource.idParam) { + const id = String(initialData?.[resource.idParam] ?? initialData?.["id"] ?? pathParams?.[resource.idParam] ?? "") if (id) params[resource.idParam] = id } diff --git a/src/components/console/templates/DetailCardTemplate.tsx b/src/components/console/templates/DetailCardTemplate.tsx index 96c4cc5..a79bd45 100644 --- a/src/components/console/templates/DetailCardTemplate.tsx +++ b/src/components/console/templates/DetailCardTemplate.tsx @@ -115,7 +115,7 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) {state.subView === "edit" && data && ( - + )} ) diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts index 86efda3..bb9101f 100644 --- a/src/hooks/use-settings.ts +++ b/src/hooks/use-settings.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react" import { useOpenAPIContext } from "@/contexts/OpenAPIContext" import { authTypeValue, readLegacySettingsFromLocalStorage } from "@/lib/db" +import { isPrivateOrLocalHost } from "@/lib/openapi/url-guard" import type { AuthType } from "@/lib/openapi/types" interface AuthSetters { @@ -62,9 +63,21 @@ export function useSettings( if (title) document.title = title if (specUrl && autoLoad) { - setTimeout(() => { - autoLoad(specUrl, baseUrl ? { baseUrlOverride: baseUrl } : undefined) - }, 0) + // A spec URL taken from the query string is attacker-controllable (share + // link) and auto-fetched without interaction — refuse internal/loopback + // hosts (SSRF). URLs the user typed or previously loaded (legacy) are exempt. + const fromQuery = !!paramSpecUrl && specUrl === paramSpecUrl + let blockedHost = false + if (fromQuery) { + try { blockedHost = isPrivateOrLocalHost(new URL(specUrl).hostname) } catch { blockedHost = true } + } + if (blockedHost) { + console.warn(`[apilot] refused to auto-load spec from an internal/loopback host: ${specUrl}`) + } else { + setTimeout(() => { + autoLoad(specUrl, baseUrl ? { baseUrlOverride: baseUrl } : undefined) + }, 0) + } } })() diff --git a/src/lib/openapi/generate-example.test.ts b/src/lib/openapi/generate-example.test.ts index c64f07c..5f355bb 100644 --- a/src/lib/openapi/generate-example.test.ts +++ b/src/lib/openapi/generate-example.test.ts @@ -26,6 +26,16 @@ describe("generateExample circular handling", () => { } expect(generateExample(schema)).not.toBeNull() }) + + it("does not prune a schema shared between sibling fields (non-circular reuse)", () => { + const shared: SchemaObject = { type: "object", properties: { value: { type: "string" } } } + const schema: SchemaObject = { type: "object", properties: { a: shared, b: shared } } + const ex = generateExample(schema) as Record | null + expect(ex).not.toBeNull() + // Both siblings must retain content; the second must not be emptied as a "cycle". + expect((ex?.a as Record | undefined)?.value).toBeDefined() + expect((ex?.b as Record | undefined)?.value).toBeDefined() + }) }) describe("generateWithVariant boundaries", () => { diff --git a/src/lib/openapi/generate-example.ts b/src/lib/openapi/generate-example.ts index c263ec0..78a7e65 100644 --- a/src/lib/openapi/generate-example.ts +++ b/src/lib/openapi/generate-example.ts @@ -277,15 +277,18 @@ export function generateWithVariant(rawSchema: SchemaObject, variantId: string): // 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 { +// `ancestors` tracks only the current recursion path (added on enter, removed on +// leave) so a schema *shared* between siblings isn't mistaken for a cycle. +function pruneCircularRefs(schema: unknown, ancestors: WeakSet = new WeakSet()): unknown { if (!schema || typeof schema !== "object") return schema - if (Array.isArray(schema)) return schema.map(s => pruneCircularRefs(s, seen)) + if (Array.isArray(schema)) return schema.map(s => pruneCircularRefs(s, ancestors)) const obj = schema as Record if (typeof obj.$ref === "string" || obj._circular || obj._unresolved) return {} - if (seen.has(schema)) return {} - seen.add(schema) + if (ancestors.has(schema)) return {} // genuine cycle: node reachable from itself + ancestors.add(schema) const out: Record = {} - for (const [k, v] of Object.entries(obj)) out[k] = pruneCircularRefs(v, seen) + for (const [k, v] of Object.entries(obj)) out[k] = pruneCircularRefs(v, ancestors) + ancestors.delete(schema) return out } diff --git a/src/lib/openapi/parser.ts b/src/lib/openapi/parser.ts index 52b865c..2b550d4 100644 --- a/src/lib/openapi/parser.ts +++ b/src/lib/openapi/parser.ts @@ -15,6 +15,7 @@ import type { ServerObject, } from "./types" import { isExternalRefAllowed, originOf } from "./url-guard" +import { readResponseTextCapped } from "@/lib/fetch-utils" export const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const export type HttpMethod = (typeof HTTP_METHODS)[number] @@ -57,7 +58,9 @@ function buildParserOptions(allowedOrigins: string[], blocked: Set): Par if (!res.ok) { throw new Error(`Failed to fetch external $ref ${file.url}: ${res.status}`) } - return await res.text() + // Cap external ref bodies too — a same-origin ref could otherwise return a + // huge response and exhaust memory before parsing. + return await readResponseTextCapped(res) }, } return { diff --git a/vite.config.ts b/vite.config.ts index 8e28343..2de8314 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,11 +62,14 @@ export default defineConfig(({ mode }) => { globPatterns: ["**/*.{js,css,html,svg}"], runtimeCaching: [ { - // Never cache credentialed spec fetches (Authorization header) — that - // would persist a protected API document to disk for later, possibly - // unauthenticated, readers. Only cache successful responses. - urlPattern: ({ request, url }: { request: Request; url: URL }) => - !request.headers.has("authorization") + // Only cache cross-origin *public* specs. Same-origin specs may be + // cookie/session-authenticated (the Cookie header isn't visible to JS, + // so we can't filter on it), and Authorization-bearing requests are + // excluded outright. Caching a protected doc could expose it to a later, + // unauthenticated reader on a shared machine. Only cache 200s. + urlPattern: ({ request, url, sameOrigin }: { request: Request; url: URL; sameOrigin: boolean }) => + !sameOrigin + && !request.headers.has("authorization") && /(?:^|\/)(?:spec|openapi|swagger|asyncapi)[^/]*\.(json|ya?ml)$/i.test(url.pathname), handler: "NetworkFirst", options: { From afaae9277efd319c798b0dc756ba8e6b6000a763 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 14:45:55 +0800 Subject: [PATCH 2/5] fix(db): preserve createdAt ordering in getEnvironmentRuntimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-transaction optimization read environments via getAllFromIndex directly, dropping getEnvironments()'s createdAt sort. The result feeds use-environments, which picks element [0] as the default environment when none is saved — and IndexedDB index order ≠ creation order, so the default baseUrl/ auth could silently switch environments. Reuse getEnvironments() (keeps the sort) and still batch-read credentials in one transaction. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/db.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/db.ts b/src/lib/db.ts index bca6cf9..4f69705 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -872,9 +872,11 @@ export async function getEnvironmentCredential(envId: string): Promise { - const db = await getDB() - const profiles = await db.getAllFromIndex("environments", "specId", specId) as EnvironmentProfile[] + // Reuse getEnvironments so the createdAt ordering is preserved (the consumer uses + // [0] as the default environment when none is saved — index order ≠ creation order). + const profiles = await getEnvironments(specId) if (profiles.length === 0) return [] + const db = await getDB() // Read all credentials within a single transaction instead of opening one DB // connection per profile. const tx = db.transaction("environmentCredentials", "readonly") From c7df2c16b349ae75c50dec6fbcc02da88268d7e3 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 17:25:35 +0800 Subject: [PATCH 3/5] fix(review): ipaddr.js for IP classification, redact-before-truncate, frame-ancestors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - url-guard: replace hand-rolled IP range checks with ipaddr.js (2500M weekly downloads, handles IPv4-mapped IPv6 hex form like ::ffff:7f00:1 that the manual regex missed — SSRF bypass). ipaddr.process() + .range() covers all loopback/private/linkLocal/uniqueLocal/carrierGradeNat/reserved ranges. - db: redact credential fields BEFORE truncating the body. truncateBody turns valid JSON into broken text that redactBody can't parse, leaving secrets in the first 2MB unmasked. - vite/cli: remove frame-ancestors from meta CSP (browsers ignore it there per spec); add X-Frame-Options: DENY as the meta-compatible clickjacking defense; document that deployers should set frame-ancestors via HTTP response header. Co-Authored-By: Claude Opus 4.8 (1M context) --- cli/src/build-template.ts | 4 ++-- src/lib/db.ts | 9 +++---- src/lib/openapi/url-guard.test.ts | 7 ++++++ src/lib/openapi/url-guard.ts | 40 +++++++++++++------------------ vite.config.ts | 7 ++++-- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/cli/src/build-template.ts b/cli/src/build-template.ts index 8ccebdf..a52c57b 100644 --- a/cli/src/build-template.ts +++ b/cli/src/build-template.ts @@ -21,7 +21,7 @@ const TEMPLATE_CSP = [ "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "object-src 'none'", "base-uri 'self'", - "frame-ancestors 'none'", + // frame-ancestors is NOT enforceable via CSP; use X-Frame-Options instead. ].join("; ") function templateCspPlugin() { @@ -30,7 +30,7 @@ function templateCspPlugin() { transformIndexHtml(html: string) { return html.replace( "", - `\n `, + `\n \n `, ) }, } diff --git a/src/lib/db.ts b/src/lib/db.ts index 4f69705..8bb7172 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -733,18 +733,19 @@ export async function addHistoryEntry(entry: Omit): Promise< try { const db = await getDB() const originalHeaders = entry.response.requestHeaders - const sanitized = { ...truncateBody(entry.response) } + // Redact BEFORE truncating — truncateBody turns valid JSON into broken text, + // which makes redactBody's JSON.parse fail and skip credential masking. + const sanitized = { ...entry.response } sanitized.requestHeaders = stripSensitiveHeaders(originalHeaders) - // The request body is stored twice (top-level + inside response); redact both, - // plus request params and the response body (may contain access/refresh tokens). sanitized.requestBody = redactBody(sanitized.requestBody) sanitized.body = redactBody(sanitized.body) ?? sanitized.body sanitized.curlCommand = redactCurlCommand(sanitized.curlCommand, originalHeaders, entry.response.requestBody) + const truncated = truncateBody(sanitized) await db.add("history", { ...entry, requestBody: redactBody(entry.requestBody), requestParams: redactParams(entry.requestParams), - response: sanitized, + response: truncated, }) } catch (err) { if ((err as DOMException)?.name === "QuotaExceededError") { diff --git a/src/lib/openapi/url-guard.test.ts b/src/lib/openapi/url-guard.test.ts index 5e3b1a5..a82c8c9 100644 --- a/src/lib/openapi/url-guard.test.ts +++ b/src/lib/openapi/url-guard.test.ts @@ -18,6 +18,13 @@ describe("url-guard", () => { expect(isPrivateOrLocalHost(h)).toBe(true) } }) + it("flags IPv4-mapped IPv6 in hex form (browser normalization)", () => { + // new URL("http://[::ffff:127.0.0.1]").hostname → "::ffff:7f00:1" + expect(isPrivateOrLocalHost("::ffff:7f00:1")).toBe(true) // 127.0.0.1 + expect(isPrivateOrLocalHost("::ffff:c0a8:1")).toBe(true) // 192.168.0.1 + expect(isPrivateOrLocalHost("::ffff:a9fe:a9fe")).toBe(true) // 169.254.169.254 + expect(isPrivateOrLocalHost("::ffff:808:808")).toBe(false) // 8.8.8.8 — public + }) 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) diff --git a/src/lib/openapi/url-guard.ts b/src/lib/openapi/url-guard.ts index 49fd0d6..0953a28 100644 --- a/src/lib/openapi/url-guard.ts +++ b/src/lib/openapi/url-guard.ts @@ -3,6 +3,18 @@ // 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. +// +// IP range classification is delegated to ipaddr.js (2500M weekly downloads, +// pure-JS, handles IPv4-mapped IPv6 in both dotted and hex forms, no CVEs). + +import * as ipaddr from "ipaddr.js" + +const NON_PUBLIC_RANGES = new Set([ + "unspecified", "broadcast", "loopback", "private", + "linkLocal", "uniqueLocal", "carrierGradeNat", "reserved", + "benchmarking", "amt", "as112v4", "as112v6", "ietf", + "6to4", "teredo", "orchid2", "droneRemoteIdProtocol", +]) /** * True if the hostname points at a loopback, link-local, private, or otherwise @@ -10,39 +22,19 @@ */ 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 + if (ipaddr.isValid(h)) { + const addr = ipaddr.process(h) + return NON_PUBLIC_RANGES.has(addr.range()) } // Bare single-label hostname (no dot) — likely an intranet name. - if (!h.includes(".")) return true + if (!h.includes(".") && !h.includes(":")) return true return false } diff --git a/vite.config.ts b/vite.config.ts index 2de8314..10e06a2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,7 +32,10 @@ function cspPlugin() { "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'", - "frame-ancestors 'none'", + // frame-ancestors is NOT enforceable via CSP (spec limitation) — it must + // come from an HTTP response header. We use X-Frame-Options as a meta-compatible + // clickjacking defense instead; deployers should additionally set + // `Content-Security-Policy: frame-ancestors 'none'` via their server/CDN config. ].join("; ") return { name: "apilot-csp", @@ -40,7 +43,7 @@ function cspPlugin() { transformIndexHtml(html: string) { return html.replace( "", - `\n `, + `\n \n `, ) }, } From be3a6c0da9980bfe437708adeab4814cc167c079 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 17:33:40 +0800 Subject: [PATCH 4/5] fix(review): block multicast IPs, drop ineffective X-Frame-Options meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - url-guard: add "multicast" to NON_PUBLIC_RANGES (224.0.0.0/4, ff00::/8) - vite/cli: remove — it's equally unenforceable via meta as frame-ancestors; clarify in comments that deployers must set the HTTP header Co-Authored-By: Claude Opus 4.8 (1M context) --- cli/src/build-template.ts | 5 +++-- src/lib/openapi/url-guard.test.ts | 5 +++++ src/lib/openapi/url-guard.ts | 2 +- vite.config.ts | 10 +++++----- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/cli/src/build-template.ts b/cli/src/build-template.ts index a52c57b..980cc73 100644 --- a/cli/src/build-template.ts +++ b/cli/src/build-template.ts @@ -21,7 +21,8 @@ const TEMPLATE_CSP = [ "script-src 'self' 'unsafe-inline' 'unsafe-eval'", "object-src 'none'", "base-uri 'self'", - // frame-ancestors is NOT enforceable via CSP; use X-Frame-Options instead. + // Clickjacking: frame-ancestors and X-Frame-Options both require HTTP headers, + // not . Static templates must rely on the hosting server's headers config. ].join("; ") function templateCspPlugin() { @@ -30,7 +31,7 @@ function templateCspPlugin() { transformIndexHtml(html: string) { return html.replace( "", - `\n \n `, + `\n `, ) }, } diff --git a/src/lib/openapi/url-guard.test.ts b/src/lib/openapi/url-guard.test.ts index a82c8c9..4d30a37 100644 --- a/src/lib/openapi/url-guard.test.ts +++ b/src/lib/openapi/url-guard.test.ts @@ -30,6 +30,11 @@ describe("url-guard", () => { expect(isPrivateOrLocalHost(h)).toBe(true) } }) + it("flags multicast addresses (IPv4 + IPv6)", () => { + expect(isPrivateOrLocalHost("224.0.0.1")).toBe(true) + expect(isPrivateOrLocalHost("239.255.255.250")).toBe(true) + expect(isPrivateOrLocalHost("ff02::1")).toBe(true) + }) it("allows normal public hostnames", () => { for (const h of ["example.com", "api.example.com", "petstore.swagger.io"]) { expect(isPrivateOrLocalHost(h)).toBe(false) diff --git a/src/lib/openapi/url-guard.ts b/src/lib/openapi/url-guard.ts index 0953a28..1cb4792 100644 --- a/src/lib/openapi/url-guard.ts +++ b/src/lib/openapi/url-guard.ts @@ -10,7 +10,7 @@ import * as ipaddr from "ipaddr.js" const NON_PUBLIC_RANGES = new Set([ - "unspecified", "broadcast", "loopback", "private", + "unspecified", "broadcast", "loopback", "private", "multicast", "linkLocal", "uniqueLocal", "carrierGradeNat", "reserved", "benchmarking", "amt", "as112v4", "as112v6", "ietf", "6to4", "teredo", "orchid2", "droneRemoteIdProtocol", diff --git a/vite.config.ts b/vite.config.ts index 10e06a2..4354761 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,10 +32,10 @@ function cspPlugin() { "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'", - // frame-ancestors is NOT enforceable via CSP (spec limitation) — it must - // come from an HTTP response header. We use X-Frame-Options as a meta-compatible - // clickjacking defense instead; deployers should additionally set - // `Content-Security-Policy: frame-ancestors 'none'` via their server/CDN config. + // Clickjacking: neither frame-ancestors (CSP) nor X-Frame-Options can be + // enforced via — both require HTTP response headers. Deployers must set + // `Content-Security-Policy: frame-ancestors 'none'` (or `X-Frame-Options: DENY`) + // in their server/CDN/Cloudflare Pages _headers config. ].join("; ") return { name: "apilot-csp", @@ -43,7 +43,7 @@ function cspPlugin() { transformIndexHtml(html: string) { return html.replace( "", - `\n \n `, + `\n `, ) }, } From f4bcfea428e3303f41eefd1a6dbeb10ab9e7be80 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 17:47:56 +0800 Subject: [PATCH 5/5] fix(deps): declare ipaddr.js in package.json + lockfile (CI frozen-install) The earlier commit added ipaddr.js to the main checkout's package.json (where pnpm add ran) but not this worktree's, so CI's `pnpm install --frozen-lockfile` installed without it and tsc failed with "Cannot find module 'ipaddr.js'". Add the dependency to package.json and regenerate the lockfile entry (incremental, +9 lines, no version drift). Verified frozen-lockfile is consistent under pnpm 10. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 1 + pnpm-lock.yaml | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/package.json b/package.json index 2ef1d9a..014cfae 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "i18next-browser-languagedetector": "^8.2.1", "idb": "^8.0.3", "input-otp": "^1.4.2", + "ipaddr.js": "^2.4.0", "libphonenumber-js": "^1.13.3", "lucide-react": "^1.17.0", "marked": "^18.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26eee95..1e6e93d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + ipaddr.js: + specifier: ^2.4.0 + version: 2.4.0 libphonenumber-js: specifier: ^1.13.3 version: 1.13.3 @@ -3929,6 +3932,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -9896,6 +9903,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.4.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9