mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
fix: resolve analytics tracking issues, provider alias mapping, and fallback calculation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
if ("max_tokens" in defaultsRecord) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -139,12 +139,18 @@ export function getNextFamilyFallback(
|
||||
currentModel: string,
|
||||
triedModels: Set<string>
|
||||
): 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}`)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user