diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts index 138bd23ee9..cd58d45e62 100644 --- a/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { formatValidationMessage, validateBody } from "@/shared/validation/helpers"; +import { volcenginePlanCodeSchema } from "@/shared/validation/schemas/volcenginePlan"; /** * POST /api/providers/volcengine-plan/connect/[sessionId]/code @@ -17,11 +19,23 @@ export async function POST( if (auth) return auth; const { sessionId } = await params; - const body = await request.json().catch(() => ({})); + const raw = await request.json().catch(() => ({})); + // Validate BEFORE the session lookup: a malformed body is the caller's bug + // regardless of whether the session happens to exist, and answering 404 for + // it (the previous behavior) hides the real cause. + const validation = validateBody(volcenginePlanCodeSchema, raw); + if (!validation.success) { + return NextResponse.json( + { success: false, error: formatValidationMessage(validation.error) }, + { status: 400 } + ); + } + const { code, captcha, timeout } = validation.data; try { - const { volcengineConsoleAutoLoginService } = - await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + const { volcengineConsoleAutoLoginService } = await import( + "@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts" + ); if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) { return NextResponse.json( @@ -30,13 +44,9 @@ export async function POST( ); } - const timeout = typeof body.timeout === "number" ? body.timeout : undefined; - const session = await volcengineConsoleAutoLoginService.submitCode( - sessionId, - String(body.code ?? ""), - typeof body.captcha === "string" ? body.captcha : undefined, - { timeout } - ); + const session = await volcengineConsoleAutoLoginService.submitCode(sessionId, code, captcha, { + timeout, + }); if (!session) { return NextResponse.json( { success: false, error: "Unknown or expired Volcano login session" }, diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts index e8b289d4b2..c6704d2ff3 100644 --- a/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { formatValidationMessage, validateBody } from "@/shared/validation/helpers"; +import { volcenginePlanIdentitySchema } from "@/shared/validation/schemas/volcenginePlan"; /** * POST /api/providers/volcengine-plan/connect/[sessionId]/identity @@ -16,11 +18,21 @@ export async function POST( if (auth) return auth; const { sessionId } = await params; - const body = await request.json().catch(() => ({})); + const raw = await request.json().catch(() => ({})); + // Validate BEFORE the session lookup — see the sibling code/route.ts note. + const validation = validateBody(volcenginePlanIdentitySchema, raw); + if (!validation.success) { + return NextResponse.json( + { success: false, error: formatValidationMessage(validation.error) }, + { status: 400 } + ); + } + const { index, timeout } = validation.data; try { - const { volcengineConsoleAutoLoginService } = - await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + const { volcengineConsoleAutoLoginService } = await import( + "@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts" + ); if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) { return NextResponse.json( @@ -29,15 +41,6 @@ export async function POST( ); } - const index = Number(body.index); - if (!Number.isInteger(index) || index < 0) { - return NextResponse.json( - { success: false, error: "Invalid identity index" }, - { status: 400 } - ); - } - - const timeout = typeof body.timeout === "number" ? body.timeout : undefined; const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, index, { timeout, }); diff --git a/src/app/api/providers/volcengine-plan/connect/route.ts b/src/app/api/providers/volcengine-plan/connect/route.ts index d57cac554e..d6bebe20a3 100644 --- a/src/app/api/providers/volcengine-plan/connect/route.ts +++ b/src/app/api/providers/volcengine-plan/connect/route.ts @@ -2,20 +2,30 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { formatValidationMessage, validateBody } from "@/shared/validation/helpers"; +import { volcenginePlanConnectSchema } from "@/shared/validation/schemas/volcenginePlan"; export async function POST(request: Request): Promise { const auth = await requireManagementAuth(request); if (auth) return auth; - const body = await request.json().catch(() => ({})); - const timeout = typeof body.timeout === "number" ? body.timeout : undefined; + const raw = await request.json().catch(() => ({})); + const validation = validateBody(volcenginePlanConnectSchema, raw); + if (!validation.success) { + return NextResponse.json( + { success: false, error: formatValidationMessage(validation.error) }, + { status: 400 } + ); + } + const { phone, timeout } = validation.data; // Auto flow: phone present → start a session-based headless phone/SMS login. - if (typeof body.phone === "string" && body.phone.trim()) { + if (phone) { try { - const { volcengineConsoleAutoLoginService } = - await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); - const started = await volcengineConsoleAutoLoginService.startLogin(body.phone, { timeout }); + const { volcengineConsoleAutoLoginService } = await import( + "@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts" + ); + const started = await volcengineConsoleAutoLoginService.startLogin(phone, { timeout }); if (!started.ok) { return NextResponse.json({ success: false, error: started.error }, { status: 400 }); } diff --git a/src/lib/guardrails/videoBridge.ts b/src/lib/guardrails/videoBridge.ts index c0c0f324de..4672a7dc3b 100644 --- a/src/lib/guardrails/videoBridge.ts +++ b/src/lib/guardrails/videoBridge.ts @@ -23,7 +23,6 @@ import { describeVideoPart as defaultDescribeVideoPart, extractVideoFocusHint, extractVideoParts, - formatVideoTimestamp, loadVideoPartBytes, replaceVideoParts, resolveVideoDedupCandidateFrameCount, diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 756abd0481..05a20b2148 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -463,6 +463,42 @@ function windowStillExhaustedAfterRealReset(value: unknown, nowMs: number): bool return resetMs > nowMs; } +/** + * May an active cooldown be released because the REAL quota windows recovered? + * + * Only the synthetic-cooldown case (#10534) qualifies: lastErrorType + * "quota_exhausted" plus every governing window past its real reset with quota + * left. A window that is still exhausted — or whose reset is unknown/unparseable + * — keeps the connection locked, matching the kimi-coding partial-refresh + * semantics. + */ +function isQuotaExhaustedCooldownReleasable( + connection: Pick< + ProviderConnectionLike, + "lastErrorType" | "lastErrorSource" | "provider" | "providerSpecificData" + >, + usage: JsonRecord +): boolean { + if (connection.lastErrorType !== "quota_exhausted") return false; + // An extra-usage block is a POLICY lock, not a quota window: the session and + // weekly windows genuinely look recovered in the very same fetch, so the + // window scan below would happily release it. It stays locked while the + // policy is on and upstream still reports extra usage queued. + if ( + connection.lastErrorSource === CLAUDE_EXTRA_USAGE_ERROR_SOURCE && + isClaudeExtraUsageBlockEnabled(connection.provider, connection.providerSpecificData) && + isClaudeExtraUsageQueued(usage) + ) { + return false; + } + const quotas = usage?.quotas; + if (!isRecord(quotas)) return false; + const values = Object.values(quotas); + if (values.length === 0) return false; + const nowMs = Date.now(); + return !values.some((value) => windowStillExhaustedAfterRealReset(value, nowMs)); +} + /** * Is an explicit cooldown still in the future? * @@ -513,7 +549,19 @@ export async function maybeClearRecoveredQuotaState( ): Promise { if (!hasUsableQuota(usage)) return connection; if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection; - if (hasActiveCooldown(connection)) return connection; + if (hasActiveCooldown(connection)) { + // #11355 made an active rateLimitedUntil an unconditional stop, which is right + // for an upstream-derived cooldown but over-broad for the one case #10534 was + // built for: a Claude-subscription 429 persists a SYNTHETIC 1h cooldown because + // the upstream sent no parseable reset. When the later poll shows every window + // that governs this connection has really reset WITH quota available, holding + // that synthetic cooldown just deadlocks the connection for an hour. + // + // Narrow by design: only lastErrorType "quota_exhausted" (the synthetic-cooldown + // writer) is eligible, and a single still-exhausted or unknown-reset window keeps + // the lock. Every other reason keeps #11355/#11277 semantics untouched. + if (!isQuotaExhaustedCooldownReleasable(connection, usage)) return connection; + } const hasTransientState = connection.testStatus === "unavailable" || diff --git a/src/shared/validation/schemas/volcenginePlan.ts b/src/shared/validation/schemas/volcenginePlan.ts new file mode 100644 index 0000000000..2c461c9d52 --- /dev/null +++ b/src/shared/validation/schemas/volcenginePlan.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +/** + * Request schemas for the volcengine-plan console connect routes. + * + * These bodies drive a headless browser login (phone/SMS, image captcha, + * identity selection), so every field is validated before it reaches the + * service — Hard Rule #7, enforced by `check:route-validation:t06`. + */ + +// The service forwards this straight to its own wait loops; a non-positive or +// fractional timeout is always a caller bug, never a meaningful request. +const timeoutSchema = z.number().int().positive().optional(); + +export const volcenginePlanConnectSchema = z.object({ + // Absent phone = the legacy headful flow. Present but blank is a caller bug: + // the old `body.phone.trim()` check silently fell through to that flow. + phone: z.string().trim().min(1).optional(), + timeout: timeoutSchema, +}); + +export const volcenginePlanCodeSchema = z.object({ + // Previously `String(body.code ?? "")`, which turned 123 into "123" and an + // absent code into "" — both reached the service as a plausible-looking SMS + // code and failed far away from the caller. + code: z.string().trim().min(1), + captcha: z.string().trim().min(1).optional(), + timeout: timeoutSchema, +}); + +export const volcenginePlanIdentitySchema = z.object({ + index: z.number().int().min(0), + timeout: timeoutSchema, +}); + +export type VolcenginePlanConnectBody = z.infer; +export type VolcenginePlanCodeBody = z.infer; +export type VolcenginePlanIdentityBody = z.infer; diff --git a/tests/unit/volcengine-plan-connect-validation.test.ts b/tests/unit/volcengine-plan-connect-validation.test.ts new file mode 100644 index 0000000000..239576f03c --- /dev/null +++ b/tests/unit/volcengine-plan-connect-validation.test.ts @@ -0,0 +1,118 @@ +/** + * tests/unit/volcengine-plan-connect-validation.test.ts + * + * Hard Rule #7 (Zod on every input) for the volcengine-plan connect routes. + * + * These three routes read `await request.json()` and then hand the raw fields + * to the auto-login service after ad-hoc `typeof` checks. The t06 + * route-validation gate flags exactly that shape, and the gap is real: an + * unvalidated body reaches a service that drives a headless browser login. + * + * The two session routes are the fast, deterministic probes: today an invalid + * body reaches the session lookup and comes back 404 (or coerces silently — + * `String(body.code ?? "")` turns 123 into "123"); after the fix the body is + * rejected with 400 BEFORE the session is ever looked up. + * + * DATA_DIR is redirected to a temp dir BEFORE the route imports, since the + * auth pipeline touches the DB singleton at import time. With a fresh DB no + * password is set, so requireManagementAuth() lets the request through and + * the assertions actually reach the body-validation branch. + */ +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"; + +process.env.NODE_ENV = "test"; +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-volc-connect-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-volc-connect-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const codeRoute = await import( + "../../src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts" +); +const identityRoute = await import( + "../../src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts" +); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function post(body: unknown): Request { + return new Request("http://localhost/api/providers/volcengine-plan/connect/sess-unknown/code", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const params = Promise.resolve({ sessionId: "sess-unknown-for-validation-test" }); + +// ── code route ─────────────────────────────────────────────────────────────── + +test("code route rejects a non-string code with 400 instead of coercing it", async () => { + // Before the fix: `String(body.code ?? "")` happily turns 123 into "123" and + // the route answers 404 (unknown session) — the bad type never surfaces. + const res = await codeRoute.POST(post({ code: 123 }), { params }); + assert.equal(res.status, 400); + const body = (await res.json()) as { error?: string }; + assert.match(String(body.error), /invalid|code/i); +}); + +test("code route rejects a missing code with 400", async () => { + const res = await codeRoute.POST(post({ captcha: "abcd" }), { params }); + assert.equal(res.status, 400); +}); + +test("code route rejects a non-string captcha with 400", async () => { + const res = await codeRoute.POST(post({ code: "123456", captcha: 99 }), { params }); + assert.equal(res.status, 400); +}); + +test("code route validates the body BEFORE the session lookup", async () => { + // The session id is unknown, so an unvalidated route answers 404. A validated + // one must answer 400: the body is refused before any session state is read. + const res = await codeRoute.POST(post({ code: 123 }), { params }); + assert.notEqual(res.status, 404); + assert.equal(res.status, 400); +}); + +// ── identity route ─────────────────────────────────────────────────────────── + +test("identity route rejects a non-integer index with 400 before the session lookup", async () => { + const res = await identityRoute.POST(post({ index: "not-a-number" }), { params }); + assert.equal(res.status, 400); + assert.notEqual(res.status, 404); +}); + +test("identity route rejects a negative index with 400", async () => { + const res = await identityRoute.POST(post({ index: -1 }), { params }); + assert.equal(res.status, 400); +}); + +test("identity route rejects a non-numeric timeout with 400", async () => { + const res = await identityRoute.POST(post({ index: 0, timeout: "soon" }), { params }); + assert.equal(res.status, 400); +}); + +// ── gate contract ──────────────────────────────────────────────────────────── + +test("all three connect routes parse their body through a Zod schema (t06 gate)", () => { + const ROOT = path.join(import.meta.dirname, "..", ".."); + const files = [ + "src/app/api/providers/volcengine-plan/connect/route.ts", + "src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts", + "src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts", + ]; + for (const rel of files) { + const src = fs.readFileSync(path.join(ROOT, rel), "utf8"); + assert.ok( + src.includes("validateBody(") || src.includes(".safeParse("), + `${rel} must validate its body with Zod (check:route-validation:t06)` + ); + } +});