diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index f1e538d172..34f1e744cc 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -633,6 +633,11 @@ export class AntigravityExecutor extends BaseExecutor { ); const finalHeaders = serializedRequest.headers; + log?.debug?.( + "TELEMETRY", + `[Antigravity] Execute - URL: ${url}, Model: ${model}, Target: ${(transformedBody as any)?.model || "unknown"}, RetryAttempt: ${retryAttemptsByUrl[urlIndex]}` + ); + const response = await fetch(url, { method: "POST", headers: finalHeaders, @@ -640,6 +645,13 @@ export class AntigravityExecutor extends BaseExecutor { signal, }); + if (!response.ok) { + log?.warn?.( + "TELEMETRY", + `[Antigravity] Error Response - URL: ${url}, Status: ${response.status}, Model: ${model}` + ); + } + // Parse retry time for 429/503 responses let retryMs = null; @@ -977,6 +989,10 @@ export class AntigravityExecutor extends BaseExecutor { }; } catch (error) { lastError = error; + log?.error?.( + "TELEMETRY", + `[Antigravity] Network/Fetch Error - URL: ${url}, Model: ${model}, Error: ${error instanceof Error ? error.message : String(error)}` + ); if (urlIndex + 1 < fallbackCount) { log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); continue; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 21a9ef7d9e..83dd52309a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -228,6 +228,19 @@ export class DefaultExecutor extends BaseExecutor { buildHeaders(credentials, stream = true) { const headers = { "Content-Type": "application/json", ...this.config.headers }; + // Allow per-provider User-Agent override via environment variable. + const providerId = this.config?.id || this.provider; + if (providerId) { + const envKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; + const envUA = process.env[envKey]?.trim(); + if (envUA) { + headers["User-Agent"] = envUA; + if ("user-agent" in headers) { + headers["user-agent"] = envUA; + } + } + } + // T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; @@ -409,7 +422,7 @@ export class DefaultExecutor extends BaseExecutor { // #1961: Map max_tokens -> max_completion_tokens for recent OpenAI models if (getTargetFormat(this.provider, credentials?.providerSpecificData) === "openai") { - const isRecentOpenAI = /^(o1|o3|gpt-5)/i.test(model); + const isRecentOpenAI = /^(o1|o3|o4|gpt-5)/i.test(model); if (isRecentOpenAI && withDefaults && typeof withDefaults === "object") { const defaultsRecord = withDefaults as Record; if ("max_tokens" in defaultsRecord) { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 34d12994ce..04e2159afe 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -20,6 +20,7 @@ import { } from "../services/modelStrip.ts"; import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; +import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; import { buildErrorBody, createErrorResult, @@ -2494,6 +2495,17 @@ export async function handleChatCore({ } } + // Rename max_tokens to max_completion_tokens if not supported (#1961) + if (!supportsMaxTokens({ provider, model })) { + if (translatedBody.max_tokens !== undefined) { + if (translatedBody.max_completion_tokens === undefined) { + translatedBody.max_completion_tokens = translatedBody.max_tokens; + } + delete translatedBody.max_tokens; + log?.debug?.("PARAMS", `Renamed max_tokens to max_completion_tokens for ${model}`); + } + } + // OpenAI's `store` parameter is not supported by most compatible providers and breaks them if (provider !== "openai" && "store" in translatedBody) { delete translatedBody.store; diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index b7812ca877..50d30f7617 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -139,12 +139,18 @@ export function getNextFamilyFallback( currentModel: string, triedModels: Set ): string | null { - const family = MODEL_FAMILIES[currentModel]; + const parsed = parseModel(currentModel); + const bareModel = parsed.model || currentModel; + const prefix = + parsed.provider || parsed.providerAlias ? `${parsed.provider || parsed.providerAlias}/` : ""; + + const family = MODEL_FAMILIES[bareModel]; if (!family) return null; for (const candidate of family) { - if (!triedModels.has(candidate)) { - return candidate; + const fullCandidate = `${prefix}${candidate}`; + if (!triedModels.has(fullCandidate)) { + return fullCandidate; } } @@ -155,16 +161,23 @@ export function getNextFamilyFallback( * Check if a model belongs to any registered family. */ export function isInModelFamily(model: string): boolean { - return model in MODEL_FAMILIES; + const parsed = parseModel(model); + const bareModel = parsed.model || model; + return bareModel in MODEL_FAMILIES; } /** * Get all members of a model's family (including itself). */ export function getModelFamily(model: string): string[] { - const family = MODEL_FAMILIES[model]; + const parsed = parseModel(model); + const bareModel = parsed.model || model; + const prefix = + parsed.provider || parsed.providerAlias ? `${parsed.provider || parsed.providerAlias}/` : ""; + + const family = MODEL_FAMILIES[bareModel]; if (!family) return [model]; - return [model, ...family]; + return [model, ...family.map((c) => `${prefix}${c}`)]; } /** diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 3b09912672..0b48332a0b 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -312,17 +312,12 @@ export function openaiToClaudeRequest(model, body, stream) { } } - // System with Claude Code prompt and cache_control - const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT }; - + // System messages and cache_control if (systemParts.length > 0) { const systemText = systemParts.join("\n"); result.system = [ - claudeCodePrompt, { type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } }, ]; - } else { - result.system = [claudeCodePrompt]; } // Thinking configuration @@ -552,16 +547,6 @@ function tryParseJSON(str) { function openaiToClaudeRequestForAntigravity(model, body, stream) { const result = openaiToClaudeRequest(model, body, stream); - // Remove Claude Code system prompt, keep only user's system messages - if (result.system && Array.isArray(result.system)) { - result.system = result.system.filter( - (block) => !block.text || !block.text.includes("You are Claude Code") - ); - if (result.system.length === 0) { - delete result.system; - } - } - // Strip prefix from tool names for Antigravity (doesn't use Claude OAuth) if (result.tools && Array.isArray(result.tools)) { result.tools = result.tools.map((tool) => { diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index de37cda8da..7b271d6fd7 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -229,6 +229,13 @@ function convertMessages(messages, tools, model) { // If last message in history is userInputMessage, use it as currentMessage if (history.length > 0 && history[history.length - 1].userInputMessage) { currentMessage = history.pop(); + } else if (!currentMessage) { + currentMessage = { + userInputMessage: { + content: "Continue", + modelId: model, + }, + }; } const firstHistoryItem = history[0]; diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 470f2db235..43609b98ec 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -74,18 +74,32 @@ function resolveModelPricing( const pLower = (providerRaw || "").toLowerCase(); let providerPricing = findKeyInsensitive(pricingByProvider, pLower); + if (!providerPricing) { + // providerAliasMap maps ID -> ALIAS. So if pLower is "codex", alias is "cx". const alias = providerAliasMap[pLower]; if (alias) { providerPricing = findKeyInsensitive(pricingByProvider, alias); - } else { - const np = pLower.replace(/-cn$/, ""); - if (np && np !== pLower) { - providerPricing = findKeyInsensitive(pricingByProvider, np); + } + } + + if (!providerPricing) { + // In case pLower was ALIAS and we want to try the ID (reverse search values) + for (const [id, alias] of Object.entries(providerAliasMap)) { + if (alias.toLowerCase() === pLower) { + providerPricing = findKeyInsensitive(pricingByProvider, id); + if (providerPricing) break; } } } + if (!providerPricing) { + const np = pLower.replace(/-cn$/, ""); + if (np && np !== pLower) { + providerPricing = findKeyInsensitive(pricingByProvider, np); + } + } + // Hardcoded known fallbacks if (!providerPricing) { if (pLower === "antigravity") providerPricing = findKeyInsensitive(pricingByProvider, "ag"); @@ -481,7 +495,7 @@ export async function GET(request: Request) { WHEN requested_model IS NOT NULL AND requested_model != '' AND model IS NOT NULL - AND LOWER(requested_model) != LOWER(model) + AND LOWER(CASE WHEN instr(requested_model, '/') > 0 THEN substr(requested_model, instr(requested_model, '/') + 1) ELSE requested_model END) != LOWER(model) AND (combo_name IS NULL OR combo_name = '') THEN 1 ELSE 0 END ) as fallbacks diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index bd937a4ef0..4251768eb5 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -34,13 +34,6 @@ const PREFIX = "enc:v1:"; const STATIC_SALT = "omniroute-field-encryption-v1"; let _staticKey: Buffer | null = null; -let _legacyDynamicKey: Buffer | null = null; -// Module-level migration flag. Safe in Node.js because: -// 1. Node.js is single-threaded — no concurrent access race conditions -// 2. decrypt() is synchronous — no interleaving between flag-set and flag-read -// 3. Used as a "check-after-decrypt" signal, not a persistent state dependency -// 4. Same pattern as _staticKey/_legacyDynamicKey cache variables above -let _migrationNeeded = false; /** Connection object with potentially encrypted credential fields. */ export interface ConnectionFields { @@ -75,39 +68,6 @@ function getStaticKey(): Buffer | null { return _staticKey; } -/** - * Derive the LEGACY key using the old dynamic salt method. - * Used exclusively for fallback decryption of tokens encrypted by older versions. - * - * The old dynamic salt was: createHash("sha256").update(secret).digest().slice(0, 16) - * This produced a different derived key than the static salt, causing incompatibility. - */ -function getLegacyDynamicKey(): Buffer | null { - if (_legacyDynamicKey !== null) return _legacyDynamicKey; - - const secret = process.env.STORAGE_ENCRYPTION_KEY; - if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; - - // This is the OLD dynamic salt derivation that caused the bug - const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); - try { - _legacyDynamicKey = scryptSync(secret, dynamicSalt, KEY_LENGTH); - } catch { - return null; - } - return _legacyDynamicKey; -} - -/** Check if any tokens were decrypted using the legacy key (indicating migration is needed). */ -export function isMigrationNeeded(): boolean { - return _migrationNeeded; -} - -/** Reset migration flag (call after migration is complete). */ -export function resetMigrationFlag(): void { - _migrationNeeded = false; -} - /** Check if encryption is enabled. */ export function isEncryptionEnabled(): boolean { return !!process.env.STORAGE_ENCRYPTION_KEY; @@ -198,23 +158,11 @@ export function decrypt(ciphertext: string | null | undefined): string | null | try { // PRIMARY: Try static-salt key first (canonical derivation) - let decrypted = tryDecryptWithKey(staticKey); + const decrypted = tryDecryptWithKey(staticKey); if (decrypted !== null) { return decrypted; } - // FALLBACK: Try legacy dynamic-salt key (backward compatibility) - const legacyKey = getLegacyDynamicKey(); - if (legacyKey) { - const legacyDecrypted = tryDecryptWithKey(legacyKey); - if (legacyDecrypted !== null) { - // Flag for migration: this token was encrypted with the legacy key - // and should be re-encrypted with the static key on next write - _migrationNeeded = true; - return legacyDecrypted; - } - } - console.error( `[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` + `Auth tag validation likely failed.` diff --git a/src/lib/db/healthCheck.ts b/src/lib/db/healthCheck.ts index c1919bccd2..5b84550ae6 100644 --- a/src/lib/db/healthCheck.ts +++ b/src/lib/db/healthCheck.ts @@ -1,10 +1,4 @@ import { normalizeComboStep } from "@/lib/combos/steps"; -import { - decryptConnectionFields, - encryptConnectionFields, - isMigrationNeeded, - resetMigrationFlag, -} from "./encryption"; type SqliteDatabase = import("better-sqlite3").Database; type JsonRecord = Record; @@ -13,8 +7,7 @@ export type DbHealthIssueType = | "integrity_check_failed" | "broken_reference" | "stale_snapshot" - | "invalid_state" - | "legacy_encryption"; + | "invalid_state"; export interface DbHealthIssue { type: DbHealthIssueType; @@ -393,83 +386,6 @@ function repairSchemaVersion(db: SqliteDatabase, expectedSchemaVersion: string): .run(expectedSchemaVersion).changes; } -function countLegacyEncryptedTokens(db: SqliteDatabase): number { - if (!hasRows(db, "provider_connections")) return 0; - - const rows = db - .prepare("SELECT api_key, access_token, refresh_token, id_token FROM provider_connections") - .all() as Array<{ - api_key?: string | null; - access_token?: string | null; - refresh_token?: string | null; - id_token?: string | null; - }>; - - let legacyCount = 0; - for (const row of rows) { - resetMigrationFlag(); - decryptConnectionFields({ - apiKey: row.api_key, - accessToken: row.access_token, - refreshToken: row.refresh_token, - idToken: row.id_token, - }); - if (isMigrationNeeded()) { - legacyCount += 1; - } - } - - return legacyCount; -} - -function repairLegacyEncryption(db: SqliteDatabase): number { - if (!hasRows(db, "provider_connections")) return 0; - - const rows = db - .prepare("SELECT id, api_key, access_token, refresh_token, id_token FROM provider_connections") - .all() as Array<{ - id: string; - api_key?: string | null; - access_token?: string | null; - refresh_token?: string | null; - id_token?: string | null; - }>; - - const updateStmt = db.prepare( - "UPDATE provider_connections SET api_key = ?, access_token = ?, refresh_token = ?, id_token = ?, updated_at = ? WHERE id = ?" - ); - - let repaired = 0; - const now = new Date().toISOString(); - - for (const row of rows) { - resetMigrationFlag(); - const camelRow = { - apiKey: row.api_key, - accessToken: row.access_token, - refreshToken: row.refresh_token, - idToken: row.id_token, - }; - - const decrypted = decryptConnectionFields(camelRow); - - if (isMigrationNeeded()) { - const reEncrypted = encryptConnectionFields(decrypted); - updateStmt.run( - reEncrypted.apiKey || null, - reEncrypted.accessToken || null, - reEncrypted.refreshToken || null, - reEncrypted.idToken || null, - now, - row.id - ); - repaired += 1; - } - } - - return repaired; -} - export function runDbHealthCheck( db: SqliteDatabase, options: RunDbHealthCheckOptions = {} @@ -621,21 +537,6 @@ export function runDbHealthCheck( } } - const legacyEncryptionCount = countLegacyEncryptedTokens(db); - if (legacyEncryptionCount > 0) { - issues.push({ - type: "legacy_encryption", - table: "provider_connections", - description: - "Provider connections contain tokens encrypted with the legacy dynamic salt derivation.", - count: legacyEncryptionCount, - }); - if (autoRepair) { - ensureBackupBeforeRepair(); - repairedCount += repairLegacyEncryption(db); - } - } - return { isHealthy: issues.length === 0, issues, diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 7157f6caad..2ae015e74b 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -235,9 +235,17 @@ export async function getPricingForModel(provider: string, model: string) { if (pricing[provider]?.[model]) return pricing[provider][model]; const { PROVIDER_ID_TO_ALIAS } = await import("@omniroute/open-sse/config/providerModels"); + // Check if provider is an ID -> map to ALIAS const alias = PROVIDER_ID_TO_ALIAS[provider]; if (alias && pricing[alias]) return pricing[alias][model] || null; + // Check if provider is an ALIAS -> map to ID (search values) + for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { + if (mappedAlias === provider && pricing[id]?.[model]) { + return pricing[id][model]; + } + } + const np = provider?.replace(/-cn$/, ""); if (np && np !== provider && pricing[np]) return pricing[np][model] || null; diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 45f1aa7c3d..f2f0887eee 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -18,6 +18,16 @@ const REASONING_UNSUPPORTED_PATTERNS = [ "antigravity/tab_", ]; +const MAX_TOKENS_UNSUPPORTED_PATTERNS = [ + "o1-preview", + "o1-mini", + "o1", + "o3-mini", + "o3", + "gpt-5.4", + "gpt-5.5", +]; + type CapabilityInput = | string | { @@ -36,6 +46,7 @@ export interface ResolvedModelCapabilities { supportsThinking: boolean | null; supportsTools: boolean | null; supportsVision: boolean | null; + supportsMaxTokens: boolean; attachment: boolean | null; structuredOutput: boolean | null; temperature: boolean | null; @@ -144,6 +155,16 @@ function heuristicReasoning(modelStr: string): boolean { return !blocked; } +function heuristicMaxTokens(modelStr: string): boolean { + const normalized = String(modelStr || "").toLowerCase(); + if (!normalized) return true; + const blocked = MAX_TOKENS_UNSUPPORTED_PATTERNS.some( + (pattern) => + normalized === pattern || normalized.endsWith(`/${pattern}`) || normalized.includes(pattern) + ); + return !blocked; +} + function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSpec | undefined { if (modelId) { const byCanonical = getModelSpec(modelId); @@ -222,6 +243,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo modalitiesInput, modalitiesOutput ), + supportsMaxTokens: heuristicMaxTokens(lookupKey), attachment: synced?.attachment ?? null, structuredOutput: synced?.structured_output ?? null, temperature: synced?.temperature ?? null, @@ -259,6 +281,11 @@ export function supportsReasoning(input: CapabilityInput): boolean { return getResolvedModelCapabilities(input).reasoning; } +export function supportsMaxTokens(input: CapabilityInput): boolean { + if (typeof input === "string" && !String(input || "").trim()) return true; + return getResolvedModelCapabilities(input).supportsMaxTokens; +} + export function capMaxOutputTokens(input: CapabilityInput, requested?: number): number { const cap = getResolvedModelCapabilities(input).maxOutputTokens; return requested ? Math.min(requested, cap) : cap; diff --git a/tests/unit/encryption.spec.ts b/tests/unit/encryption.spec.ts index 00da12c739..c9b5658105 100644 --- a/tests/unit/encryption.spec.ts +++ b/tests/unit/encryption.spec.ts @@ -66,94 +66,6 @@ describe("encryption module", () => { }); }); - describe("legacy fallback: decrypt values encrypted with dynamic-salt key", () => { - it("should decrypt legacy-encrypted value using fallback key", async () => { - const secret = "test-secret-key-12345"; - const plaintext = "legacy-api-token"; - - // Manually create a legacy-encrypted value - const legacyEncrypted = createLegacyEncrypted(plaintext, secret); - - vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); - vi.resetModules(); - - const { decrypt } = await import("@/lib/db/encryption"); - - const decrypted = decrypt(legacyEncrypted); - expect(decrypted).toBe(plaintext); - }); - - it("should handle multiple legacy-encrypted values", async () => { - const secret = "test-secret-key-12345"; - const values = ["legacy1", "legacy2", "legacy3"]; - - const legacyEncrypted = values.map((v) => createLegacyEncrypted(v, secret)); - - vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); - vi.resetModules(); - - const { decrypt } = await import("@/lib/db/encryption"); - - const decrypted = legacyEncrypted.map((e) => decrypt(e)); - expect(decrypted).toEqual(values); - }); - }); - - describe("migration flag: after legacy decrypt, isMigrationNeeded() returns true", () => { - it("should set migration flag when decrypting legacy value", async () => { - const secret = "test-secret-key-12345"; - const plaintext = "legacy-token"; - const legacyEncrypted = createLegacyEncrypted(plaintext, secret); - - vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); - vi.resetModules(); - - const { decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); - - expect(isMigrationNeeded()).toBe(false); - - const decrypted = decrypt(legacyEncrypted); - expect(decrypted).toBe(plaintext); - expect(isMigrationNeeded()).toBe(true); - }); - - it("should NOT set migration flag when decrypting static-key value", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); - - const plaintext = "modern-token"; - const encrypted = encrypt(plaintext); - - expect(isMigrationNeeded()).toBe(false); - - const decrypted = decrypt(encrypted!); - expect(decrypted).toBe(plaintext); - expect(isMigrationNeeded()).toBe(false); - }); - }); - - describe("resetMigrationFlag() clears the flag", () => { - it("should reset migration flag after it was set", async () => { - const secret = "test-secret-key-12345"; - const plaintext = "legacy-token"; - const legacyEncrypted = createLegacyEncrypted(plaintext, secret); - - vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); - vi.resetModules(); - - const { decrypt, isMigrationNeeded, resetMigrationFlag } = - await import("@/lib/db/encryption"); - - decrypt(legacyEncrypted); - expect(isMigrationNeeded()).toBe(true); - - resetMigrationFlag(); - expect(isMigrationNeeded()).toBe(false); - }); - }); - describe("passthrough mode: no STORAGE_ENCRYPTION_KEY set → plaintext stored", () => { it("should return plaintext when encryption key is not set", async () => { // No STORAGE_ENCRYPTION_KEY set @@ -267,27 +179,6 @@ describe("encryption module", () => { expect(decrypted.apiKey).toBe("plain-api-key"); expect(decrypted.accessToken).toBeUndefined(); }); - - it("should set migration flag when decrypting legacy-encrypted connection fields", async () => { - const secret = "test-secret-key-12345"; - const legacyApiKey = createLegacyEncrypted("legacy-api-key", secret); - - vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); - vi.resetModules(); - - const { decryptConnectionFields, isMigrationNeeded } = await import("@/lib/db/encryption"); - - const conn = { - id: "conn-123", - apiKey: legacyApiKey, - }; - - expect(isMigrationNeeded()).toBe(false); - - const decrypted = decryptConnectionFields(conn); - expect(decrypted.apiKey).toBe("legacy-api-key"); - expect(isMigrationNeeded()).toBe(true); - }); }); describe("edge cases: null/undefined inputs, already-encrypted, malformed ciphertext", () => { @@ -463,95 +354,4 @@ describe("encryption module", () => { expect(isEncryptionEnabled()).toBe(false); }); }); - - describe("new encryptions always use static salt key", () => { - it("should encrypt with static key and decrypt without migration flag", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); - - const plaintext = "new-token"; - const encrypted = encrypt(plaintext); - - expect(isMigrationNeeded()).toBe(false); - - const decrypted = decrypt(encrypted!); - expect(decrypted).toBe(plaintext); - expect(isMigrationNeeded()).toBe(false); - }); - - it("should verify multiple new encryptions use static key", async () => { - vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); - vi.resetModules(); - - const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); - - const values = ["token1", "token2", "token3"]; - const encrypted = values.map((v) => encrypt(v)); - - expect(isMigrationNeeded()).toBe(false); - - const decrypted = encrypted.map((e) => decrypt(e!)); - expect(decrypted).toEqual(values); - expect(isMigrationNeeded()).toBe(false); - }); - }); - - describe("EXACT bug scenario: dynamic salt → legacy fallback → migration flag", () => { - it("should reproduce the exact bug: value encrypted with dynamic salt, decrypt recovers via legacy fallback, migration flag set", async () => { - const secret = "test-secret-key-12345"; - const plaintext = "health-check-token"; - - // Simulate the bug: health-check thread encrypted with dynamic salt - const buggyEncrypted = createLegacyEncrypted(plaintext, secret); - - // Main API tries to decrypt - vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); - vi.resetModules(); - - const { decrypt, isMigrationNeeded } = await import("@/lib/db/encryption"); - - expect(isMigrationNeeded()).toBe(false); - - // Should recover via legacy fallback - const decrypted = decrypt(buggyEncrypted); - expect(decrypted).toBe(plaintext); - - // Migration flag should be set - expect(isMigrationNeeded()).toBe(true); - }); - - it("should verify re-encryption after migration flag is set", async () => { - const secret = "test-secret-key-12345"; - const plaintext = "health-check-token"; - - const legacyEncrypted = createLegacyEncrypted(plaintext, secret); - - vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); - vi.resetModules(); - - const { decrypt, encrypt, isMigrationNeeded, resetMigrationFlag } = - await import("@/lib/db/encryption"); - - // Decrypt legacy value - const decrypted = decrypt(legacyEncrypted); - expect(decrypted).toBe(plaintext); - expect(isMigrationNeeded()).toBe(true); - - // Re-encrypt with static key - const reEncrypted = encrypt(decrypted!); - expect(reEncrypted).toMatch(/^enc:v1:/); - expect(reEncrypted).not.toBe(legacyEncrypted); - - // Reset migration flag - resetMigrationFlag(); - expect(isMigrationNeeded()).toBe(false); - - // Verify new encryption decrypts without migration flag - const finalDecrypted = decrypt(reEncrypted!); - expect(finalDecrypted).toBe(plaintext); - expect(isMigrationNeeded()).toBe(false); - }); - }); });