From a1d6ff5fbf4ae2440d5534b1656d5e8e3cc07a3e Mon Sep 17 00:00:00 2001 From: ANIRUDDHA ADAK Date: Sun, 30 Aug 2026 12:36:52 +0530 Subject: [PATCH 1/6] fix(api): preserve caller-provided X-Correlation-Id on chat completions (#11760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boarded with #11741 (a duplicate fix for the same underlying issue #11739). Compared both implementations directly: this one is technically superior — a dedicated resolveIncomingCorrelationId() helper that strips CRLF (header-injection prevention) and bounds length to 1-256 chars, with 4 unit tests covering those edge cases. #11741's simpler `header || generateRequestId()` has no sanitization. Closing #11741 with credit. Validated in a combined worktree: typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green; 84/84 + 43/43 focused tests pass across this batch. Thanks for the careful sanitization work. --- src/app/api/v1/chat/completions/route.ts | 10 ++++- src/shared/utils/correlationPreserve.ts | 12 ++++++ .../unit/chat-completions-correlation.test.ts | 37 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/shared/utils/correlationPreserve.ts create mode 100644 tests/unit/chat-completions-correlation.test.ts diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index c8c73faabb..a812075b09 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -3,6 +3,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { callCloudWithMachineId } from "@/shared/utils/cloud"; import { handleChat } from "@/sse/handlers/chat"; import { generateRequestId } from "@/shared/utils/requestId"; +import { resolveIncomingCorrelationId } from "@/shared/utils/correlationPreserve.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; @@ -243,8 +244,13 @@ export async function POST(request) { // paths) drop the meta the docs promise. const compressionRequestHeader = readCompressionRequestHeader(request); + // #11739: preserve caller-provided X-Correlation-Id when present; generate only when absent. + const callerCorrelationId = resolveIncomingCorrelationId( + request.headers.get("x-correlation-id") + ); + if (wantsStreaming) { - const reqId = generateRequestId(); + const reqId = callerCorrelationId ?? generateRequestId(); // Wrap the real handler response, not the synthetic early-keepalive response. If the // client cancels while handleChat is still pending, earlyStreamKeepalive will cancel the // eventual handler body; only that confirmed cleanup releases heavyweight capacity. @@ -265,7 +271,7 @@ export async function POST(request) { return finishAdmission( withCompressionHeaderEcho( - await handleChat(request, null, parsedBody), + await handleChat(request, null, parsedBody, callerCorrelationId ?? undefined), compressionRequestHeader ) ); diff --git a/src/shared/utils/correlationPreserve.ts b/src/shared/utils/correlationPreserve.ts new file mode 100644 index 0000000000..99b1070d0f --- /dev/null +++ b/src/shared/utils/correlationPreserve.ts @@ -0,0 +1,12 @@ +/** + * Resolve caller-provided X-Correlation-Id for preservation (#11739). + * Returns sanitized value when present and within bounds (1-256 chars), otherwise null. + * Strips CRLF to prevent header injection, trims whitespace. + */ +export function resolveIncomingCorrelationId( + headerValue: string | null | undefined +): string | null { + const raw = (headerValue ?? "").trim().replace(/[\r\n]/g, ""); + if (raw.length === 0 || raw.length > 256) return null; + return raw; +} diff --git a/tests/unit/chat-completions-correlation.test.ts b/tests/unit/chat-completions-correlation.test.ts new file mode 100644 index 0000000000..17042bee57 --- /dev/null +++ b/tests/unit/chat-completions-correlation.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { resolveIncomingCorrelationId } = await import( + "../../src/shared/utils/correlationPreserve.ts" +); + +test("resolveIncomingCorrelationId preserves valid caller ID", () => { + assert.equal(resolveIncomingCorrelationId("caller-correlation"), "caller-correlation"); + assert.equal(resolveIncomingCorrelationId(" spaced-id "), "spaced-id"); + assert.equal(resolveIncomingCorrelationId("abc-123_XYZ"), "abc-123_XYZ"); +}); + +test("resolveIncomingCorrelationId sanitizes header injection", () => { + assert.equal(resolveIncomingCorrelationId("evil\r\nInjected: true"), "evilInjected: true"); + assert.equal(resolveIncomingCorrelationId("with\nnewline"), "withnewline"); + assert.equal(resolveIncomingCorrelationId("with\rcarriage"), "withcarriage"); +}); + +test("resolveIncomingCorrelationId rejects empty and overlong", () => { + assert.equal(resolveIncomingCorrelationId(null), null); + assert.equal(resolveIncomingCorrelationId(undefined), null); + assert.equal(resolveIncomingCorrelationId(""), null); + assert.equal(resolveIncomingCorrelationId(" "), null); + const long = "a".repeat(257); + assert.equal(resolveIncomingCorrelationId(long), null); + assert.equal(resolveIncomingCorrelationId("a".repeat(256)), "a".repeat(256)); +}); + +test("resolveIncomingCorrelationId trims before length check", () => { + // 256 chars plus surrounding spaces should still be valid after trim + const spacedLong = " " + "a".repeat(256) + " "; + assert.equal(resolveIncomingCorrelationId(spacedLong), "a".repeat(256)); + // 257 after trim should be rejected + const spacedTooLong = " " + "a".repeat(257) + " "; + assert.equal(resolveIncomingCorrelationId(spacedTooLong), null); +}); From 55691e041681f8802c3c71856f9cdcd341adec63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Vi=E1=BA=BFt=20Tu=E1=BA=A5n?= Date: Sun, 30 Aug 2026 14:07:06 +0700 Subject: [PATCH 2/6] fix(usage): allow quota refresh for FREE lease-reserved connections (#11758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boarded in a combined worktree with 6 other PRs: typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green. Verified the root-cause diagnosis directly against the code: isConnectionUnavailableToAuxiliaryActivity() does return true for any connection reachable by an active exclusive lease regardless of whether the lease is actively serving a request, confirming the fix's scoping is correct. The change is surgically limited to providerLimits.ts's live-usage-fetch path — the shared isolation function and its other call sites (warmupScheduler, quotaAutoPing, modelTestRunner, etc.) are untouched. Well tested (214 lines across 3 test files). Thanks for tracking this down. --- .../11758-usage-refresh-exclusive-lease.md | 1 + src/lib/usage/providerLimits.ts | 15 +- ...xclusive-lease-auxiliary-isolation.test.ts | 149 +++++++++++++++++- ...ve-lease-connection-test-isolation.test.ts | 65 +++++++- ...ard-session-lease-bypass-inventory.test.ts | 9 +- 5 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 changelog.d/fixes/11758-usage-refresh-exclusive-lease.md diff --git a/changelog.d/fixes/11758-usage-refresh-exclusive-lease.md b/changelog.d/fixes/11758-usage-refresh-exclusive-lease.md new file mode 100644 index 0000000000..9ff032a930 --- /dev/null +++ b/changelog.d/fixes/11758-usage-refresh-exclusive-lease.md @@ -0,0 +1 @@ +- **fix(usage):** quota and usage refresh no longer 409 when an exclusive lease reserves the connection ([#11758](https://github.com/diegosouzapw/OmniRoute/pull/11758)) — thanks @TheDemonTuan diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 8daf5d7925..79af5cecad 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -14,7 +14,6 @@ import { import { syncToCloud } from "@/lib/cloudSync"; import { setQuotaCache } from "@/domain/quotaCache"; import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage"; -import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; import { clearRecoveredProviderState } from "@/sse/services/auth"; import { getMachineId } from "@/shared/utils/machine"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; @@ -860,9 +859,6 @@ async function fetchLiveProviderLimitsWithOptions( connection: ProviderConnectionLike; usage: JsonRecord; }> { - if (await isConnectionUnavailableToAuxiliaryActivity(connectionId)) { - throw withStatus(new Error("Usage refresh deferred while an exclusive lease is active"), 409); - } let connection = (await getProviderConnectionById( connectionId )) as unknown as ProviderConnectionLike | null; @@ -1073,16 +1069,7 @@ export async function syncAllProviderLimits( const connectionRows = (await getProviderConnections({ isActive: true, })) as unknown as ProviderConnectionLike[]; - const connections = ( - await Promise.all( - connectionRows.map(async (connection) => ({ - connection, - blocked: await isConnectionUnavailableToAuxiliaryActivity(connection.id), - })) - ) - ) - .filter(({ connection, blocked }) => isSupportedUsageConnection(connection) && !blocked) - .map(({ connection }) => connection); + const connections = connectionRows.filter(isSupportedUsageConnection); const cacheEntries: Array<{ connectionId: string; entry: ProviderLimitsCacheEntry }> = []; const caches: Record = {}; const errors: Record = {}; diff --git a/tests/unit/exclusive-lease-auxiliary-isolation.test.ts b/tests/unit/exclusive-lease-auxiliary-isolation.test.ts index 199c2aba7c..50b0f27d36 100644 --- a/tests/unit/exclusive-lease-auxiliary-isolation.test.ts +++ b/tests/unit/exclusive-lease-auxiliary-isolation.test.ts @@ -24,12 +24,14 @@ const translator = await import("../../src/app/api/translator/send/route.ts"); const translatorPreview = await import("../../src/app/api/translator/translate/route.ts"); const modelTests = await import("../../src/lib/api/modelTestRunner.ts"); const vnc = await import("../../src/lib/vncSession/service.ts"); +const usageRoute = await import("../../src/app/api/usage/[connectionId]/route.ts"); +const providerLimits = await import("../../src/lib/usage/providerLimits.ts"); const OWNER = "vlo_UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU"; -async function seedConnection(name: string): Promise<{ id: string }> { +async function seedConnection(name: string, provider = "openai"): Promise<{ id: string }> { return (await providers.createProviderConnection({ - provider: "openai", + provider, authType: "apikey", name, apiKey: `sk-${name}`, @@ -52,6 +54,10 @@ async function resetStorage(): Promise { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); externalCalls = 0; + globalThis.fetch = async () => { + externalCalls += 1; + throw new Error("unexpected external provider/model call"); + }; } test.beforeEach(resetStorage); @@ -190,3 +196,142 @@ test("browser-login harvest rejects ACTIVE leased connections before credential ); assert.equal(externalCalls, 0); }); + +test("usage refresh allows FREE lease-reserved connection and queries provider quota", async () => { + const connection = await seedConnection("deepseek-free-lease", "deepseek"); + await markLeaseOnly(connection.id); + + globalThis.fetch = async (input: RequestInfo | URL) => { + externalCalls += 1; + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://api.deepseek.com/user/balance") { + return new Response( + JSON.stringify({ + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "10.00", + granted_balance: "0.00", + topped_up_balance: "10.00", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + throw new Error(`unexpected fetch call: ${url}`); + }; + + const response = await usageRoute.GET( + new Request(`http://omniroute.local/api/usage/${connection.id}`), + { params: Promise.resolve({ connectionId: connection.id }) } + ); + + assert.equal(response.status, 200); + assert.equal(externalCalls, 1); + const data = (await response.json()) as { quotas?: { credits_usd?: { remaining?: number } } }; + assert.equal(data?.quotas?.credits_usd?.remaining, 10); +}); + +test("usage refresh allows ACTIVE leased connection and queries provider quota", async () => { + const connection = await seedConnection("deepseek-active-lease", "deepseek"); + await markLeaseOnly(connection.id); + const acquired = leases.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: "managed-key", + provider: "deepseek", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + + globalThis.fetch = async (input: RequestInfo | URL) => { + externalCalls += 1; + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://api.deepseek.com/user/balance") { + return new Response( + JSON.stringify({ + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "10.00", + granted_balance: "0.00", + topped_up_balance: "10.00", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + throw new Error(`unexpected fetch call: ${url}`); + }; + + const response = await usageRoute.GET( + new Request(`http://omniroute.local/api/usage/${connection.id}`), + { params: Promise.resolve({ connectionId: connection.id }) } + ); + + assert.equal(response.status, 200); + assert.equal(externalCalls, 1); + const data = (await response.json()) as { quotas?: { credits_usd?: { remaining?: number } } }; + assert.equal(data?.quotas?.credits_usd?.remaining, 10); +}); + +test("syncAllProviderLimits refreshes all active supported connections regardless of lease state", async () => { + const freeConn = await seedConnection("deepseek-bulk-free", "deepseek"); + const activeConn = await seedConnection("deepseek-bulk-active", "deepseek"); + await markLeaseOnly(freeConn.id); + await markLeaseOnly(activeConn.id); + + const acquired = leases.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: "managed-key", + provider: "deepseek", + connectionId: activeConn.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + + globalThis.fetch = async (input: RequestInfo | URL) => { + externalCalls += 1; + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://api.deepseek.com/user/balance") { + return new Response( + JSON.stringify({ + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "10.00", + granted_balance: "0.00", + topped_up_balance: "10.00", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + throw new Error(`unexpected fetch call: ${url}`); + }; + + const result = await providerLimits.syncAllProviderLimits({ + source: "manual", + concurrency: 2, + }); + + assert.ok(result.caches[freeConn.id]); + assert.ok(result.caches[activeConn.id]); + assert.equal(externalCalls, 2); +}); diff --git a/tests/unit/exclusive-lease-connection-test-isolation.test.ts b/tests/unit/exclusive-lease-connection-test-isolation.test.ts index 957f678dee..376ce673b4 100644 --- a/tests/unit/exclusive-lease-connection-test-isolation.test.ts +++ b/tests/unit/exclusive-lease-connection-test-isolation.test.ts @@ -71,20 +71,13 @@ test("connection verification skips an ACTIVE exclusive lease before any probe o assert.equal(row.last_error, null); }); -test("model discovery, quota refresh, and reset-credit paths reject ACTIVE leased connections", async () => { +test("model discovery and reset-credit paths reject ACTIVE leased connections", async () => { const response = await providerModels.GET( new Request("http://omniroute.local/api/providers/leased-test-connection/models"), { params: { id: "leased-test-connection" } } ); assert.equal(response.status, 409); - await assert.rejects( - providerLimits.fetchLiveProviderLimits("leased-test-connection"), - (error: unknown) => - error instanceof Error && - (error as Error & { status?: number }).status === 409 && - /exclusive lease/i.test(error.message) - ); await assert.rejects( codexResetCredits.listCodexResetCredits("leased-test-connection"), (error: unknown) => @@ -94,3 +87,59 @@ test("model discovery, quota refresh, and reset-credit paths reject ACTIVE lease ); assert.equal(externalCalls, 0); }); + +test("quota refresh proceeds on an ACTIVE leased usage-supported connection", async () => { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO provider_connections + (id, provider, auth_type, name, api_key, is_active, test_status, created_at, updated_at) + VALUES (?, ?, 'apikey', ?, ?, 1, 'active', ?, ?)` + ).run( + "leased-quota-connection", + "deepseek", + "leased quota connection", + "synthetic-deepseek-key", + new Date().toISOString(), + new Date().toISOString() + ); + const acquired = leases.acquireExclusiveConnectionLease({ + leaseOwnerId: "vlo_QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ", + apiKeyId: "managed-quota-key", + provider: "deepseek", + connectionId: "leased-quota-connection", + }); + assert.equal(acquired.kind, "ACQUIRED"); + + externalCalls = 0; + globalThis.fetch = async (input: RequestInfo | URL) => { + externalCalls += 1; + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://api.deepseek.com/user/balance") { + return new Response( + JSON.stringify({ + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "10.00", + granted_balance: "0.00", + topped_up_balance: "10.00", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + throw new Error(`unexpected fetch call: ${url}`); + }; + + const result = await providerLimits.fetchLiveProviderLimits("leased-quota-connection"); + assert.equal(result.connection.id, "leased-quota-connection"); + const quotas = result.usage.quotas as { credits_usd?: { remaining?: number } }; + assert.equal(quotas?.credits_usd?.remaining, 10); + assert.equal(externalCalls, 1); +}); diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 82f95ff822..6f0365c67b 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -310,11 +310,14 @@ test("managed request surfaces are fenced centrally or rejected before independe "src/lib/api/modelTestRunner.ts", "src/lib/services/quotaAutoPing.ts", "src/lib/usage/codexResetCredits.ts", - "src/lib/usage/providerLimits.ts", "src/lib/vncSession/service.ts", "src/lib/warmupScheduler.ts", "src/shared/services/modelSyncScheduler.ts", ].map((file) => fs.readFileSync(path.join(REPO_ROOT, file), "utf8")); + const unfencedUsageRefreshSource = fs.readFileSync( + path.join(REPO_ROOT, "src/lib/usage/providerLimits.ts"), + "utf8" + ); assert.match(chat, /parseManagedLeaseRequestContext\(request\.headers\)/); assert.match(chat, /isManagedComboUnsupported/); @@ -329,6 +332,10 @@ test("managed request surfaces are fenced centrally or rejected before independe for (const source of auxiliaryIsolationSources) { assert.match(source, /isConnectionUnavailableToAuxiliaryActivity/); } + // Usage/quota refresh is read-only admin telemetry (#11758) and must not inherit + // the exclusive-lease auxiliary fence that blocks model tests, translation, VNC, + // reset-credits, and warmup. + assert.doesNotMatch(unfencedUsageRefreshSource, /isConnectionUnavailableToAuxiliaryActivity/); }); test("SQLite claim-race retry removes only the lost candidate from the same policy-valid set", () => { From faebf6de5f1f6e4c78c6281ffb175eea21821d95 Mon Sep 17 00:00:00 2001 From: santosraju99-hub Date: Sun, 30 Aug 2026 12:37:18 +0530 Subject: [PATCH 3/6] fix(shared): block cloud-metadata hosts under default remote-image guard (#11755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boarded in a combined worktree: typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green; 10/10 focused tests pass. Real SSRF gap confirmed — the default "block-metadata" guard mode fell through to the unchecked parseOutboundUrl() while 3 other call sites of the same guard mode already routed through parseAndValidateNonMetadataUrl(). Good catch that the existing test suite only ever exercised "public-only" explicitly. Retargeted from the stale release/v3.8.50 base to release/v3.8.51. Thanks for closing a real cloud-metadata SSRF exposure. --- src/shared/network/remoteImageFetch.ts | 5 ++- tests/unit/remote-image-fetch.test.ts | 49 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts index 2e8fc130b1..2f982a0bd9 100644 --- a/src/shared/network/remoteImageFetch.ts +++ b/src/shared/network/remoteImageFetch.ts @@ -4,6 +4,7 @@ import { Agent, fetch as undiciFetch } from "undici"; import { type OutboundUrlGuardMode, isPrivateHost, + parseAndValidateNonMetadataUrl, parseAndValidatePublicUrl, parseOutboundUrl, } from "@/shared/network/outboundUrlGuard"; @@ -51,7 +52,9 @@ export type RemoteMediaFetchOptions = RemoteImageFetchOptions; export type RemoteMediaFetchResult = RemoteImageFetchResult; function validateRemoteImageUrl(input: string | URL, guard: OutboundUrlGuardMode) { - return guard === "public-only" ? parseAndValidatePublicUrl(input) : parseOutboundUrl(input); + if (guard === "public-only") return parseAndValidatePublicUrl(input); + if (guard === "block-metadata") return parseAndValidateNonMetadataUrl(input); + return parseOutboundUrl(input); } function requireHttps(url: URL, enabled: boolean): URL { diff --git a/tests/unit/remote-image-fetch.test.ts b/tests/unit/remote-image-fetch.test.ts index 80ff9e3e7c..8014397165 100644 --- a/tests/unit/remote-image-fetch.test.ts +++ b/tests/unit/remote-image-fetch.test.ts @@ -56,3 +56,52 @@ test("fetchRemoteImage blocks redirects to private image hosts", async () => { /Blocked private or local provider URL/ ); }); + +// The default guard mode (no `guard` option passed, matching production callers that rely on +// `getProviderOutboundGuard()`'s local-first default) is "block-metadata". Every other test in +// this file passes `guard: "public-only"` explicitly, which never exercised this branch — the +// gap that let `validateRemoteImageUrl()`'s fall-through to the unchecked `parseOutboundUrl()` +// for cloud-metadata hosts go undetected. +test("fetchRemoteImage blocks cloud-metadata hosts under the default block-metadata guard", async () => { + let called = false; + + await assert.rejects( + () => + fetchRemoteImage("http://169.254.169.254/latest/meta-data", { + fetchImpl: async () => { + called = true; + return new Response("unexpected"); + }, + }), + /Blocked cloud-metadata endpoint/ + ); + + assert.equal(called, false); +}); + +test("fetchRemoteImage allows private/LAN image hosts under the default block-metadata guard", async () => { + const result = await fetchRemoteImage("http://192.168.1.50:8080/local.png", { + fetchImpl: async () => + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + }); + + assert.equal(result.buffer.toString("base64"), "AQID"); +}); + +test("fetchRemoteImage blocks redirects to cloud-metadata hosts under the default block-metadata guard", async () => { + await assert.rejects( + () => + fetchRemoteImage("https://cdn.example.com/redirect.png", { + fetchImpl: async () => + new Response(null, { + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data" }, + }), + lookup: publicLookup, + }), + /Blocked cloud-metadata endpoint/ + ); +}); From c2c97aff82ac934942ced900209c7dc97624acb9 Mon Sep 17 00:00:00 2001 From: Sabee Ur Rehman Khan Date: Sun, 30 Aug 2026 12:07:28 +0500 Subject: [PATCH 4/6] ci: add API route TypeScript regression gate (#11705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boarded in a combined worktree: typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green. Clean, self-contained addition (5 new files, 0 modifications to existing code) that mirrors the existing dashboard-typecheck baseline-ratchet pattern. Thanks for closing a real coverage gap — API routes had no dedicated typecheck gate. --- .github/workflows/api-route-typecheck.yml | 36 ++ config/quality/api-typecheck-baseline.json | 419 +++++++++++++++++++ scripts/check/check-api-typecheck.mjs | 145 +++++++ tests/unit/build/check-api-typecheck.test.ts | 87 ++++ tsconfig.typecheck-api.json | 8 + 5 files changed, 695 insertions(+) create mode 100644 .github/workflows/api-route-typecheck.yml create mode 100644 config/quality/api-typecheck-baseline.json create mode 100644 scripts/check/check-api-typecheck.mjs create mode 100644 tests/unit/build/check-api-typecheck.test.ts create mode 100644 tsconfig.typecheck-api.json diff --git a/.github/workflows/api-route-typecheck.yml b/.github/workflows/api-route-typecheck.yml new file mode 100644 index 0000000000..11691db096 --- /dev/null +++ b/.github/workflows/api-route-typecheck.yml @@ -0,0 +1,36 @@ +name: API Route Typecheck + +on: + pull_request: + branches: + - main + - "release/**" + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + api-typecheck: + name: API Route Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "24" + cache: npm + - uses: ./.github/actions/npm-ci-retry + - name: Reject new API-route TypeScript diagnostics + run: node scripts/check/check-api-typecheck.mjs + - name: API typecheck gate unit tests + run: node --import tsx/esm --test tests/unit/build/check-api-typecheck.test.ts diff --git a/config/quality/api-typecheck-baseline.json b/config/quality/api-typecheck-baseline.json new file mode 100644 index 0000000000..5c7d7bbfe7 --- /dev/null +++ b/config/quality/api-typecheck-baseline.json @@ -0,0 +1,419 @@ +{ + "open-sse/transformer/responsesTransformer.ts": { + "TS2353": 1 + }, + "open-sse/utils/progressTracker.ts": { + "TS2353": 1 + }, + "open-sse/utils/sseHeartbeat.ts": { + "TS2353": 1 + }, + "open-sse/utils/stream.ts": { + "TS2353": 1 + }, + "src/app/api/assess/route.ts": { + "TS2339": 1 + }, + "src/app/api/cache/route.ts": { + "TS2339": 1 + }, + "src/app/api/cli-tools/all-statuses/route.ts": { + "TS2339": 1 + }, + "src/app/api/cli-tools/claude-settings/route.ts": { + "TS2339": 1 + }, + "src/app/api/cli-tools/cline-settings/route.ts": { + "TS2339": 5 + }, + "src/app/api/cli-tools/codex-settings/route.ts": { + "TS2345": 2 + }, + "src/app/api/cli-tools/grok-build-settings/route.ts": { + "TS2304": 1 + }, + "src/app/api/cli-tools/hermes-agent-settings/route.ts": { + "TS2345": 1 + }, + "src/app/api/cli-tools/letta-settings/route.ts": { + "TS2339": 1 + }, + "src/app/api/cli-tools/omp-settings/route.ts": { + "TS2339": 8 + }, + "src/app/api/cli-tools/qwen-settings/route.ts": { + "TS2322": 1 + }, + "src/app/api/combos/auto/route.ts": { + "TS2322": 1 + }, + "src/app/api/combos/test/route.ts": { + "TS2345": 1, + "TS2339": 1 + }, + "src/app/api/compression/compare/route.ts": { + "TS2345": 1 + }, + "src/app/api/compression/preview/route.ts": { + "TS2345": 1 + }, + "src/app/api/context/combos/[id]/route.ts": { + "TS2345": 1 + }, + "src/app/api/context/combos/route.ts": { + "TS2345": 1 + }, + "src/app/api/copilot/chat/route.ts": { + "TS2345": 1 + }, + "src/app/api/guardrails/test/route.ts": { + "TS2554": 1 + }, + "src/app/api/internal/codex-responses-ws/route.ts": { + "TS2740": 1, + "TS2339": 7 + }, + "src/app/api/keys/[id]/route.ts": { + "TS2339": 1 + }, + "src/app/api/local/redis/start/route.ts": { + "TS2339": 1 + }, + "src/app/api/local/redis/stop/route.ts": { + "TS2339": 1 + }, + "src/app/api/logs/[id]/route.ts": { + "TS2322": 1 + }, + "src/app/api/model-capability-overrides/route.ts": { + "TS2339": 1 + }, + "src/app/api/model-combo-mappings/route.ts": { + "TS2339": 1 + }, + "src/app/api/models/alias/route.ts": { + "TS2339": 5 + }, + "src/app/api/models/route.ts": { + "TS2345": 3, + "TS2538": 1 + }, + "src/app/api/monitoring/health/route.ts": { + "TS2322": 1 + }, + "src/app/api/oauth/codex/import-token/route.ts": { + "TS2339": 3 + }, + "src/app/api/oauth/codex/import/route.ts": { + "TS2554": 1, + "TS2353": 1, + "TS2339": 3 + }, + "src/app/api/oauth/cursor/login/poll/route.ts": { + "TS2554": 1 + }, + "src/app/api/oauth/kiro/auto-import/route.ts": { + "TS2345": 1 + }, + "src/app/api/omniroute/route/preview/route.ts": { + "TS2345": 1 + }, + "src/app/api/playground/presets/[id]/route.ts": { + "TS2339": 3 + }, + "src/app/api/provider-nodes/validate/route.ts": { + "TS2339": 2 + }, + "src/app/api/providers/[id]/login/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/[id]/models/route.ts": { + "TS2367": 1, + "TS2339": 2, + "TS2322": 2, + "TS2554": 2, + "TS2345": 3 + }, + "src/app/api/providers/[id]/refresh-cursor/route.ts": { + "TS2352": 1 + }, + "src/app/api/providers/[id]/refresh/route.ts": { + "TS2345": 1, + "TS2698": 1, + "TS2339": 6 + }, + "src/app/api/providers/[id]/sync-models/route.ts": { + "TS2345": 1 + }, + "src/app/api/providers/[id]/test/route.ts": { + "TS2362": 1, + "TS2698": 1 + }, + "src/app/api/providers/free-onboarding/route.ts": { + "TS2345": 1 + }, + "src/app/api/providers/health-autopilot/actions/route.ts": { + "TS2339": 1 + }, + "src/app/api/providers/route.ts": { + "TS2352": 1, + "TS2322": 2, + "TS2345": 4 + }, + "src/app/api/providers/test-batch/route.ts": { + "TS2345": 4 + }, + "src/app/api/providers/validate/route.ts": { + "TS2322": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/route.ts": { + "TS2739": 1, + "TS2339": 1 + }, + "src/app/api/radar/local-model-state/route.ts": { + "TS2339": 4 + }, + "src/app/api/resilience/model-cooldowns/route.ts": { + "TS2339": 1 + }, + "src/app/api/services/_shared/installRoute.ts": { + "TS2339": 1 + }, + "src/app/api/settings/cache-config/route.ts": { + "TS2339": 1, + "TS2322": 1 + }, + "src/app/api/settings/database/route.ts": { + "TS2345": 1 + }, + "src/app/api/settings/models-dev/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/obsidian/webdav/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/proxies/bulk-import/route.ts": { + "TS2345": 1 + }, + "src/app/api/settings/proxy/cloudflare-deploy/route.ts": { + "TS2769": 1, + "TS2322": 2 + }, + "src/app/api/settings/proxy/deno-deploy/route.ts": { + "TS2322": 4 + }, + "src/app/api/settings/proxy/vercel-deploy/route.ts": { + "TS2322": 3 + }, + "src/app/api/settings/reasoning-routing-rules/[id]/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/reasoning-routing-rules/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/reasoning-routing-rules/simulate/route.ts": { + "TS2322": 1, + "TS2339": 1 + }, + "src/app/api/system/env/repair/route.ts": { + "TS2578": 1, + "TS2353": 3 + }, + "src/app/api/system/version/route.ts": { + "TS2769": 1 + }, + "src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts": { + "TS2769": 1 + }, + "src/app/api/tools/traffic-inspector/internal/ingest/route.ts": { + "TS1117": 2, + "TS2345": 1 + }, + "src/app/api/tools/traffic-inspector/ws/route.ts": { + "TS2578": 1 + }, + "src/app/api/translator/send/route.ts": { + "TS2345": 1, + "TS2322": 1, + "TS2339": 1 + }, + "src/app/api/translator/translate/route.ts": { + "TS2345": 1, + "TS2322": 1 + }, + "src/app/api/tunnels/cloudflared/route.ts": { + "TS2339": 1 + }, + "src/app/api/tunnels/ngrok/route.ts": { + "TS2339": 1 + }, + "src/app/api/tunnels/tailscale/routeUtils.ts": { + "TS2339": 1 + }, + "src/app/api/usage/analytics/route.ts": { + "TS2352": 15 + }, + "src/app/api/usage/combo-health-autopilot/route.ts": { + "TS2769": 2 + }, + "src/app/api/v1/batches/route.ts": { + "TS2339": 1 + }, + "src/app/api/v1/chatgpt-web/image/[id]/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/classify/route.ts": { + "TS2322": 1 + }, + "src/app/api/v1/files/[id]/content/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/files/route.ts": { + "TS2339": 1 + }, + "src/app/api/v1/images/edits/route.ts": { + "TS2339": 21, + "TS2322": 5 + }, + "src/app/api/v1/messages/count_tokens/route.ts": { + "TS2339": 2, + "TS2322": 1 + }, + "src/app/api/v1/music/generations/route.ts": { + "TS2322": 1, + "TS2345": 1 + }, + "src/app/api/v1/ocr/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/provider-plugin-manifest/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/providers/[provider]/embeddings/route.ts": { + "TS2339": 3, + "TS2322": 1 + }, + "src/app/api/v1/providers/[provider]/images/generations/route.ts": { + "TS2339": 5 + }, + "src/app/api/v1/rerank/route.ts": { + "TS2339": 2 + }, + "src/app/api/v1/segment/route.ts": { + "TS2322": 1 + }, + "src/app/api/v1/session-leases/route.ts": { + "TS2339": 4, + "TS2345": 1 + }, + "src/app/api/v1/speech-to-text/route.ts": { + "TS2353": 1 + }, + "src/app/api/v1/text-to-speech/[voiceId]/route.ts": { + "TS2353": 1 + }, + "src/app/api/v1/web/fetch/route.ts": { + "TS2339": 1 + }, + "src/app/api/v1beta/models/route.ts": { + "TS2345": 1, + "TS2538": 1 + }, + "src/app/api/version-manager/restart/route.ts": { + "TS2339": 1 + }, + "src/app/api/version-manager/start/route.ts": { + "TS2339": 1 + }, + "src/app/api/version-manager/stop/route.ts": { + "TS2339": 1 + }, + "src/app/api/webhooks/[id]/route.ts": { + "TS2554": 1 + }, + "src/app/api/webhooks/[id]/test/route.ts": { + "TS2352": 2 + }, + "src/app/api/webhooks/route.ts": { + "TS2554": 1, + "TS2345": 1 + }, + "src/lib/db/tierConfig.ts": { + "TS2345": 2 + }, + "src/lib/guardrails/videoBridgeHelpers.ts": { + "TS2488": 1, + "TS2365": 2, + "TS2322": 1, + "TS2345": 1 + }, + "src/lib/monitoring/comboHealthAutopilot.ts": { + "TS2305": 1, + "TS2345": 1 + }, + "src/lib/monitoring/providerHealthAutopilot.ts": { + "TS2352": 4 + }, + "src/lib/omnirouteStatus.ts": { + "TS2322": 1, + "TS2558": 1 + }, + "src/lib/providerModels/managedModelImport.ts": { + "TS2352": 4 + }, + "src/lib/proxySubscription/parse.ts": { + "TS2345": 3 + }, + "src/lib/quota/quotaAnalytics.ts": { + "TS2769": 1 + }, + "src/lib/quota/quotaResetTimers.ts": { + "TS2769": 2 + }, + "src/lib/usage/comboForecast.ts": { + "TS2345": 1 + }, + "src/lib/usage/comboHealth.ts": { + "TS2345": 1 + }, + "src/lib/usage/comboScoringInspector.ts": { + "TS2352": 1, + "TS2741": 1 + }, + "src/lib/usage/providerWindowCosts.ts": { + "TS2322": 2, + "TS2558": 5, + "TS2339": 12, + "TS2345": 1 + }, + "src/lib/vscode/modelPresentation.ts": { + "TS2554": 1 + }, + "src/lib/ws/handshake.ts": { + "TS2339": 1 + }, + "src/mitm/detection/index.ts": { + "TS2741": 1 + }, + "src/mitm/inspector/httpProxyServer.ts": { + "TS2769": 1 + }, + "src/shared/schemas/cliCatalog.ts": { + "TS2554": 2 + } +} diff --git a/scripts/check/check-api-typecheck.mjs b/scripts/check/check-api-typecheck.mjs new file mode 100644 index 0000000000..441e87834e --- /dev/null +++ b/scripts/check/check-api-typecheck.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// scripts/check/check-api-typecheck.mjs +// API-route-scoped typecheck gate (#11601). +// +// `typecheck:core` uses a curated file allowlist, the dashboard typecheck gate +// only covers src/app/(dashboard)/**, and Next builds ignore TypeScript build +// errors. That leaves src/app/api/** without a blocking typecheck gate. +// +// This gate runs `tsc` scoped to src/app/api/**/*.{ts,tsx} via +// tsconfig.typecheck-api.json and compares live diagnostics against a frozen +// per-file/per-TS-code count baseline. New diagnostics or count increases fail; +// reductions are reported as improvements and can be ratcheted with --update. +// +// Run: +// node scripts/check/check-api-typecheck.mjs +// node scripts/check/check-api-typecheck.mjs --update + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const TSCONFIG = path.join(ROOT, "tsconfig.typecheck-api.json"); +const BASELINE_PATH = path.join(ROOT, "config/quality/api-typecheck-baseline.json"); +const UPDATE = process.argv.includes("--update"); + +const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/; + +export function parseTscOutput(raw) { + const counts = {}; + for (const line of String(raw).split("\n")) { + const match = TSC_ERROR_LINE.exec(line); + if (!match) continue; + const [, file, , , code] = match; + if (!counts[file]) counts[file] = {}; + counts[file][code] = (counts[file][code] || 0) + 1; + } + return counts; +} + +export function diffAgainstBaseline(live, baseline) { + const regressions = []; + const improvements = []; + + for (const [file, codes] of Object.entries(live)) { + for (const [code, liveCount] of Object.entries(codes)) { + const baselineCount = (baseline[file] && baseline[file][code]) || 0; + if (liveCount > baselineCount) { + regressions.push({ file, code, liveCount, baselineCount }); + } else if (liveCount < baselineCount) { + improvements.push({ file, code, liveCount, baselineCount }); + } + } + } + + for (const [file, codes] of Object.entries(baseline)) { + for (const [code, baselineCount] of Object.entries(codes)) { + const liveCount = (live[file] && live[file][code]) || 0; + if (liveCount === 0 && baselineCount > 0) { + improvements.push({ file, code, liveCount: 0, baselineCount }); + } + } + } + + return { regressions, improvements }; +} + +function runTsc() { + try { + return execFileSync( + process.platform === "win32" ? "npx.cmd" : "npx", + ["tsc", "--pretty", "false", "--noEmit", "-p", TSCONFIG], + { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, cwd: ROOT } + ); + } catch (err) { + if (err.stdout) return String(err.stdout); + throw err; + } +} + +function loadBaseline() { + if (!fs.existsSync(BASELINE_PATH)) return {}; + return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); +} + +function writeBaseline(counts) { + fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n"); +} + +function main() { + if (!fs.existsSync(TSCONFIG)) { + process.stderr.write(`[api-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`); + process.exit(2); + } + + console.log("[api-typecheck] Running tsc scoped to src/app/api/**…"); + const stdout = runTsc(); + const live = parseTscOutput(stdout); + const baseline = loadBaseline(); + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + const liveErrorCount = Object.values(live).reduce( + (sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0), + 0 + ); + console.log(`apiTypecheckErrors=${liveErrorCount}`); + + if (UPDATE) { + writeBaseline(live); + console.log(`[api-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`); + process.exit(0); + } + + if (improvements.length > 0) { + console.log( + `[api-typecheck] ${improvements.length} baselined error(s) no longer present ` + + `— run 'node scripts/check/check-api-typecheck.mjs --update' to ratchet the baseline down:\n` + + improvements + .map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`) + .join("\n") + ); + } + + if (regressions.length > 0) { + process.stderr.write( + `[api-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` + + `under src/app/api/ not covered by the frozen baseline:\n` + + regressions + .map((r) => ` ✗ ${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`) + .join("\n") + + `\n\nFix new API-route TypeScript regressions rather than widening the baseline.\n` + ); + process.exit(1); + } + + console.log( + `[api-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.` + ); + process.exit(0); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main(); +} diff --git a/tests/unit/build/check-api-typecheck.test.ts b/tests/unit/build/check-api-typecheck.test.ts new file mode 100644 index 0000000000..f7129c0cd4 --- /dev/null +++ b/tests/unit/build/check-api-typecheck.test.ts @@ -0,0 +1,87 @@ +// tests/unit/build/check-api-typecheck.test.ts +// Hermetic tests for the API typecheck gate's parsing and baseline-diff logic. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseTscOutput, + diffAgainstBaseline, +} from "../../../scripts/check/check-api-typecheck.mjs"; + +test("parseTscOutput: parses an API-route TS2554 regression", () => { + const raw = + `src/app/api/v1/models/route.ts(42,7): error TS2554: Expected 2 arguments, but got 3.\n` + + `src/app/api/v1/models/route.ts(52,7): error TS2554: Expected 2 arguments, but got 3.\n`; + + assert.deepEqual(parseTscOutput(raw), { + "src/app/api/v1/models/route.ts": { TS2554: 2 }, + }); +}); + +test("parseTscOutput: ignores non-error lines", () => { + const raw = + `src/app/api/foo/route.ts(1,1): error TS2339: Property 'bar' does not exist.\n` + + `Found 1 error in 1 file.\n`; + + assert.deepEqual(parseTscOutput(raw), { + "src/app/api/foo/route.ts": { TS2339: 1 }, + }); +}); + +test("parseTscOutput: returns an empty map for clean output", () => { + assert.deepEqual(parseTscOutput("Found 0 errors.\n"), {}); +}); + +test("diffAgainstBaseline: flags a new API diagnostic", () => { + const live = { "src/app/api/v1/models/route.ts": { TS2554: 1 } }; + const { regressions, improvements } = diffAgainstBaseline(live, {}); + + assert.deepEqual(regressions, [ + { + file: "src/app/api/v1/models/route.ts", + code: "TS2554", + liveCount: 1, + baselineCount: 0, + }, + ]); + assert.equal(improvements.length, 0); +}); + +test("diffAgainstBaseline: accepts an unchanged frozen diagnostic count", () => { + const baseline = { "src/app/api/foo/route.ts": { TS2339: 2 } }; + const live = { "src/app/api/foo/route.ts": { TS2339: 2 } }; + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 0); +}); + +test("diffAgainstBaseline: fails a count increase", () => { + const baseline = { "src/app/api/foo/route.ts": { TS2339: 1 } }; + const live = { "src/app/api/foo/route.ts": { TS2339: 2 } }; + const { regressions } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 1); + assert.equal(regressions[0].baselineCount, 1); + assert.equal(regressions[0].liveCount, 2); +}); + +test("diffAgainstBaseline: reports a count decrease as an improvement", () => { + const baseline = { "src/app/api/foo/route.ts": { TS2339: 2 } }; + const live = { "src/app/api/foo/route.ts": { TS2339: 1 } }; + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 1); + assert.equal(improvements[0].liveCount, 1); +}); + +test("diffAgainstBaseline: reports a disappeared diagnostic as an improvement", () => { + const baseline = { "src/app/api/foo/route.ts": { TS2339: 2 } }; + const { regressions, improvements } = diffAgainstBaseline({}, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 1); + assert.equal(improvements[0].liveCount, 0); + assert.equal(improvements[0].baselineCount, 2); +}); diff --git a/tsconfig.typecheck-api.json b/tsconfig.typecheck-api.json new file mode 100644 index 0000000000..8c6b6e246d --- /dev/null +++ b/tsconfig.typecheck-api.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": false + }, + "include": ["src/app/api/**/*.ts", "src/app/api/**/*.tsx"] +} From 1c37fff056374313952cd83c3eb4a9b2407a6163 Mon Sep 17 00:00:00 2001 From: Sabee Ur Rehman Khan Date: Sun, 30 Aug 2026 12:12:08 +0500 Subject: [PATCH 5/6] fix(memory): honor category filter in GET /api/memory (#11699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boarded with #11756 (a duplicate fix for the same underlying issue #11650). Compared both implementations directly: this one is technically superior — guards the json_extract() call with json_valid(metadata) so malformed/legacy metadata returns no match instead of throwing a 500, and covers genericBackend.ts/obsidianBackend.ts in addition to sqliteBackend.ts. #11756 only touched SQLite and had no malformed-JSON guard. Closing #11756 with credit. Resynced onto the updated release tip: the test file's `await import("../../src/lib/localDb.ts")` broke after #12055 deleted the barrel earlier this session (your branch forked before that migration) — fixed to import updateSettings directly from @/lib/db/settings, matching the pattern already used by other integration tests. typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green; 4/4 integration + 35/35 vitest pass after resync. Thanks for the thorough, well-tested fix. --- src/app/api/memory/route.ts | 2 + .../memory/__tests__/generic-backend.test.ts | 6 +- src/lib/memory/backend.ts | 1 + src/lib/memory/genericBackend.ts | 3 + src/lib/memory/obsidianBackend.ts | 1 + src/lib/memory/sqliteBackend.ts | 1 + src/lib/memory/store.ts | 8 ++ .../memory-category-filter.test.ts | 130 ++++++++++++++++++ 8 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 tests/integration/memory-category-filter.test.ts diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts index 7c3d2b7ac4..a68f9c7b97 100644 --- a/src/app/api/memory/route.ts +++ b/src/app/api/memory/route.ts @@ -38,11 +38,13 @@ export async function GET(request: Request) { const apiKeyId = searchParams.get("apiKeyId") || undefined; const type = (searchParams.get("type") as any) || undefined; const sessionId = searchParams.get("sessionId") || undefined; + const category = searchParams.get("category")?.trim() || undefined; const result = await memoryManager.list({ apiKeyId, type, sessionId, + category, query, limit: paginationParams.limit, offset: diff --git a/src/lib/memory/__tests__/generic-backend.test.ts b/src/lib/memory/__tests__/generic-backend.test.ts index 0e85da9840..c35439032c 100644 --- a/src/lib/memory/__tests__/generic-backend.test.ts +++ b/src/lib/memory/__tests__/generic-backend.test.ts @@ -369,7 +369,7 @@ describe("GenericMemoryBackend", () => { test("applies custom query param names", async () => { const b = createBackend({ - queryParams: { apiKeyId: "owner", limit: "count" }, + queryParams: { apiKeyId: "owner", category: "memoryCategory", limit: "count" }, }); const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url: string) => { if (url.toString().endsWith("/health")) { @@ -378,14 +378,16 @@ describe("GenericMemoryBackend", () => { return new Response(JSON.stringify({ data: [], total: 0, byType: {} }), { status: 200 }); }); - await b.list({ apiKeyId: "key-1", limit: 5 }); + await b.list({ apiKeyId: "key-1", category: "codegraph", limit: 5 }); const listUrl = fetchMock.mock.calls.find( ([url]) => !url.toString().endsWith("/health") )![0] as string; expect(listUrl).toContain("owner=key-1"); + expect(listUrl).toContain("memoryCategory=codegraph"); expect(listUrl).toContain("count=5"); expect(listUrl).not.toContain("apiKeyId="); + expect(listUrl).not.toContain("category="); }); }); diff --git a/src/lib/memory/backend.ts b/src/lib/memory/backend.ts index ebc4906e31..06e71bee55 100644 --- a/src/lib/memory/backend.ts +++ b/src/lib/memory/backend.ts @@ -21,6 +21,7 @@ export interface MemoryFilter { apiKeyId?: string; type?: MemoryType; sessionId?: string; + category?: string; query?: string; limit?: number; offset?: number; diff --git a/src/lib/memory/genericBackend.ts b/src/lib/memory/genericBackend.ts index 5e84e7217c..8d18b24d31 100644 --- a/src/lib/memory/genericBackend.ts +++ b/src/lib/memory/genericBackend.ts @@ -118,6 +118,7 @@ export interface GenericBackendConfig { maxTokens?: string; // default: "maxTokens" type?: string; // default: "type" sessionId?: string; // default: "sessionId" + category?: string; // default: "category" orderBy?: string; // default: "orderBy" orderDir?: string; // default: "orderDir" options?: string; // default: "options" @@ -181,6 +182,7 @@ export class GenericMemoryBackend implements MemoryBackend { maxTokens: this.config.queryParams?.maxTokens ?? "maxTokens", type: this.config.queryParams?.type ?? "type", sessionId: this.config.queryParams?.sessionId ?? "sessionId", + category: this.config.queryParams?.category ?? "category", orderBy: this.config.queryParams?.orderBy ?? "orderBy", orderDir: this.config.queryParams?.orderDir ?? "orderDir", options: this.config.queryParams?.options ?? "options", @@ -222,6 +224,7 @@ export class GenericMemoryBackend implements MemoryBackend { if (filter.apiKeyId) out[qp.apiKeyId] = filter.apiKeyId; if (filter.type) out[qp.type] = filter.type; if (filter.sessionId) out[qp.sessionId] = filter.sessionId; + if (filter.category) out[qp.category] = filter.category; if (filter.limit !== undefined) out[qp.limit] = String(filter.limit); if (filter.offset !== undefined) out[qp.offset] = String(filter.offset); if (filter.orderBy) out[qp.orderBy] = filter.orderBy; diff --git a/src/lib/memory/obsidianBackend.ts b/src/lib/memory/obsidianBackend.ts index a4014488fc..d36bfbdf8d 100644 --- a/src/lib/memory/obsidianBackend.ts +++ b/src/lib/memory/obsidianBackend.ts @@ -231,6 +231,7 @@ export class ObsidianBackend implements MemoryBackend { if (filter.apiKeyId && memory.apiKeyId !== filter.apiKeyId) continue; if (filter.type && memory.type !== filter.type) continue; if (filter.sessionId && memory.sessionId !== filter.sessionId) continue; + if (filter.category && memory.metadata.category !== filter.category) continue; memories.push(memory); byType[memory.type] = (byType[memory.type] || 0) + 1; diff --git a/src/lib/memory/sqliteBackend.ts b/src/lib/memory/sqliteBackend.ts index a708825896..93c343fe7d 100644 --- a/src/lib/memory/sqliteBackend.ts +++ b/src/lib/memory/sqliteBackend.ts @@ -64,6 +64,7 @@ export class SQLiteBackend implements MemoryBackend { apiKeyId: filter.apiKeyId, type: filter.type, sessionId: filter.sessionId, + category: filter.category, query: filter.query, limit: filter.limit, offset: filter.offset, diff --git a/src/lib/memory/store.ts b/src/lib/memory/store.ts index e0441eb3b2..5233c3f5b7 100644 --- a/src/lib/memory/store.ts +++ b/src/lib/memory/store.ts @@ -468,6 +468,7 @@ export async function listMemories(filters: { apiKeyId?: string; type?: MemoryType; sessionId?: string; + category?: string; query?: string; limit?: number; offset?: number; @@ -494,6 +495,13 @@ export async function listMemories(filters: { whereParams.push(filters.sessionId); } + if (typeof filters.category === "string" && filters.category.trim().length > 0) { + whereClauses.push( + "json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.category') = ?" + ); + whereParams.push(filters.category.trim()); + } + if (typeof filters.query === "string" && filters.query.trim().length > 0) { const likeQuery = `%${filters.query.trim().toLowerCase()}%`; whereClauses.push("(LOWER(content) LIKE ? OR LOWER(key) LIKE ?)"); diff --git a/tests/integration/memory-category-filter.test.ts b/tests/integration/memory-category-filter.test.ts new file mode 100644 index 0000000000..f66fd47834 --- /dev/null +++ b/tests/integration/memory-category-filter.test.ts @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-memory-category-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { updateSettings } = await import("../../src/lib/db/settings.ts"); +const localDb = { updateSettings }; +const { GET } = await import("../../src/app/api/memory/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function insertMemoryRow({ + id, + category, + type = "factual", + sessionId = "session-a", + metadata, +}: { + id: string; + category?: string; + type?: string; + sessionId?: string; + metadata?: string; +}) { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + const serializedMetadata = + metadata ?? JSON.stringify(category === undefined ? {} : { category }); + + db.prepare( + `INSERT INTO memories ( + id, api_key_id, session_id, type, key, content, metadata, created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + "key-a", + sessionId, + type, + `memory:${id}`, + `content ${id}`, + serializedMetadata, + now, + now, + null + ); +} + +async function getMemories(query = "") { + const response = await GET(new Request(`http://localhost/api/memory${query}`)); + assert.equal(response.status, 200); + return response.json(); +} + +test.beforeEach(async () => { + await resetStorage(); + await localDb.updateSettings({ requireLogin: false }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /api/memory filters by metadata.category and keeps totals in sync", async () => { + insertMemoryRow({ id: "codegraph-1", category: "codegraph", type: "factual" }); + insertMemoryRow({ id: "codegraph-2", category: "codegraph", type: "semantic" }); + insertMemoryRow({ id: "decision-1", category: "decision", type: "episodic" }); + + const body = await getMemories("?category=codegraph"); + + assert.deepEqual( + body.data.map((memory: { id: string }) => memory.id).sort(), + ["codegraph-1", "codegraph-2"] + ); + assert.equal(body.total, 2); + assert.equal(body.stats.total, 2); + assert.deepEqual(body.stats.byType, { factual: 1, semantic: 1 }); +}); + +test("GET /api/memory composes category with existing filters and pagination", async () => { + insertMemoryRow({ id: "keep-1", category: "decision", type: "episodic", sessionId: "session-a" }); + insertMemoryRow({ id: "keep-2", category: "decision", type: "episodic", sessionId: "session-a" }); + insertMemoryRow({ id: "wrong-type", category: "decision", type: "factual", sessionId: "session-a" }); + insertMemoryRow({ id: "wrong-session", category: "decision", type: "episodic", sessionId: "session-b" }); + + const body = await getMemories( + "?category=decision&type=episodic&sessionId=session-a&limit=1&offset=1" + ); + + assert.equal(body.data.length, 1); + assert.ok(["keep-1", "keep-2"].includes(body.data[0].id)); + assert.equal(body.total, 2); + assert.equal(body.stats.total, 2); + assert.deepEqual(body.stats.byType, { episodic: 2 }); +}); + +test("GET /api/memory returns no matches for an unknown category", async () => { + insertMemoryRow({ id: "known", category: "codegraph" }); + + const body = await getMemories("?category=missing"); + + assert.deepEqual(body.data, []); + assert.equal(body.total, 0); + assert.equal(body.stats.total, 0); + assert.deepEqual(body.stats.byType, {}); +}); + +test("GET /api/memory ignores malformed metadata when applying category filter", async () => { + insertMemoryRow({ id: "valid", category: "codegraph" }); + insertMemoryRow({ id: "malformed", metadata: "{not-json" }); + + const filtered = await getMemories("?category=codegraph"); + assert.deepEqual( + filtered.data.map((memory: { id: string }) => memory.id), + ["valid"] + ); + assert.equal(filtered.total, 1); + + const unfiltered = await getMemories(); + assert.equal(unfiltered.total, 2); +}); From ccee48d34a5a6d8f0ba33c5a8e1c1423d86e1151 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 30 Aug 2026 04:23:09 -0300 Subject: [PATCH 6/6] =?UTF-8?q?fix(db):=20drop=20three=20consumer-less=201?= =?UTF-8?q?proxy=20exports=20=E2=80=94=20dead-code=20base-red=20on=20relea?= =?UTF-8?q?se/v3.8.51=20after=20the=20barrel=20deletion=20(#12055)=20(#120?= =?UTF-8?q?87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(cli): align the nodes --base-url contract test with #12033 #11860 asserted that `nodes add/update/validate` must NOT register `--base-url` (reserved for the global server target); #12033 (issue #11999) then registered it on purpose so `omniroute nodes add --provider p --base-url ` stops being rejected by Commander's global option. Both PRs landed and the older test turned the base red on unit shard 2/4 (`Unit Tests fast-path (2/4)`, run 33293442568). The test now asserts the current contract: both flags are registered and each parses into its own option; the server-target/payload separation keeps its own test right below. * test(mutation): register lkgp-stale-pin-exhaustion-11911 in tap.testFiles 38e2baa879 (#11911) added a unit test covering src/shared/utils/circuitBreaker.ts without listing it in stryker.conf.json tap.testFiles, so check:mutation-test-coverage --strict (Fast Quality Gates) is red on the release tip. * fix(db): drop three consumer-less 1proxy exports the deleted localDb barrel was masking 50bc8ab8aa (#12055) removed the @/lib/localDb barrel; its re-exports were the only thing keeping getOneproxyStats / deleteOneproxyProxy / clearAllOneproxyProxies (and the private mapStatsRow + OneproxyStats type) 'used' for knip. The 1proxy routes are 308 compat redirects to /api/settings/free-proxies since v3.8.4, so nothing calls them: check:dead-code went 413 -> 419 on the release tip (baseline 416). Back to 416 with typecheck:core, eslint and check:db-rules green. * chore(mutation): drop the duplicate tap.testFiles entry — #12082 already registered it --- src/lib/db/oneproxy.ts | 79 ------------------------------------------ 1 file changed, 79 deletions(-) diff --git a/src/lib/db/oneproxy.ts b/src/lib/db/oneproxy.ts index 900c58c08f..e2a5fc30b9 100644 --- a/src/lib/db/oneproxy.ts +++ b/src/lib/db/oneproxy.ts @@ -24,15 +24,6 @@ export interface OneproxyProxyRecord { updatedAt: string; } -export interface OneproxyStats { - total: number; - active: number; - avgQuality: number | null; - lastValidated: string | null; - byProtocol: Array<{ protocol: string; count: number }>; - byCountry: Array<{ countryCode: string; count: number }>; -} - interface OneproxyUpsertInput { ip: string; port: number; @@ -73,19 +64,6 @@ function mapProxyRow(row: unknown): OneproxyProxyRecord { }; } -function mapStatsRow(row: unknown) { - const r = toRecord(row); - return { - total: Number(r.total) || 0, - active: Number(r.active) || 0, - avgQuality: - r.avg_quality !== null && r.avg_quality !== undefined - ? Math.round(Number(r.avg_quality) * 100) / 100 - : null, - lastValidated: typeof r.last_validated === "string" ? r.last_validated : null, - }; -} - export async function listOneproxyProxies(options?: { protocol?: string; countryCode?: string; @@ -121,47 +99,6 @@ export async function listOneproxyProxies(options?: { return rows.map(mapProxyRow); } -export async function getOneproxyStats(): Promise { - const db = getDbInstance(); - - const statsRow = db - .prepare( - `SELECT - COUNT(*) as total, - SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, - AVG(quality_score) as avg_quality, - MAX(last_validated) as last_validated - FROM proxy_registry WHERE source = 'oneproxy'` - ) - .get(); - - const stats = mapStatsRow(statsRow); - - const byProtocol = db - .prepare( - "SELECT type as protocol, COUNT(*) as count FROM proxy_registry WHERE source = 'oneproxy' GROUP BY type ORDER BY count DESC" - ) - .all() as Array; - - const byCountry = db - .prepare( - "SELECT country_code as countryCode, COUNT(*) as count FROM proxy_registry WHERE source = 'oneproxy' AND country_code IS NOT NULL GROUP BY country_code ORDER BY count DESC LIMIT 20" - ) - .all() as Array; - - return { - ...stats, - byProtocol: byProtocol.map((r) => ({ - protocol: String(r.protocol || "unknown"), - count: Number(r.count) || 0, - })), - byCountry: byCountry.map((r) => ({ - countryCode: String(r.countryCode || "unknown"), - count: Number(r.count) || 0, - })), - }; -} - export async function upsertOneproxyProxy( input: OneproxyUpsertInput ): Promise<{ proxy: OneproxyProxyRecord | null; action: "created" | "updated" }> { @@ -236,22 +173,6 @@ export async function getOneproxyProxyById(id: string): Promise { - const db = getDbInstance(); - const result = db - .prepare("DELETE FROM proxy_registry WHERE id = ? AND source = 'oneproxy'") - .run(id); - backupDbFile("pre-write"); - return result.changes > 0; -} - -export async function clearAllOneproxyProxies(): Promise { - const db = getDbInstance(); - const result = db.prepare("DELETE FROM proxy_registry WHERE source = 'oneproxy'").run(); - backupDbFile("pre-write"); - return result.changes; -} - export async function getOneproxyProxyForRotation(options?: { strategy?: "random" | "quality" | "sequential"; }): Promise {