diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index a73a95af8e..7d76f99296 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -27,6 +27,24 @@ import { hasClaudeCodeWildcardPermission, matchesWildcardPattern, } from "./apiKeys/modelPermissions"; +import { + parseAllowedModels, + parseAllowedCombos, + parseNoLog, + parseAutoResolve, + parseDisableNonPublicModels, + parseAllowUsageCommand, + parseIsActive, + parseAccessSchedule, + parseRateLimits, + parseAllowedConnections, + parseAllowedQuotas, + parseStringList, + parseNullableTimestamp, + parseIsBanned, + parseStreamDefaultMode, +} from "./apiKeys/rowParsers"; +import type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; // ──────────────── Performance Optimizations ──────────────── @@ -40,18 +58,8 @@ interface CacheEntry { value: TValue; } -export interface RateLimitRule { - limit: number; - window: number; -} - -export interface AccessSchedule { - enabled: boolean; - from: string; - until: string; - days: number[]; - tz: string; -} +// Re-exported for the historical public surface (moved to ./apiKeys/types). +export type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; interface ApiKeyMetadata { id: string; @@ -277,11 +285,6 @@ function evictIfNeeded(cache: Map) { } } - - - - - async function getModelPermissionCandidates(modelId: string): Promise { const candidates = new Set(); addModelCandidate(candidates, modelId); @@ -312,8 +315,6 @@ async function getModelPermissionCandidates(modelId: string): Promise return Array.from(candidates); } - - async function getPublishedModelLookupTarget( modelId: string ): Promise<{ providerId: string; modelId: string } | null> { @@ -341,8 +342,6 @@ async function getPublishedModelLookupTarget( return null; } - - function ensureApiKeyColumn( db: ApiKeysDbLike, columnNames: Set, @@ -486,156 +485,6 @@ export async function getApiKeyById(id: string) { return camelRow; } -/** - * Helper function to safely parse allowed_models JSON - */ -function parseAllowedModels(value: unknown): string[] { - if (!value || typeof value !== "string" || value.trim() === "") { - return []; - } - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) - ? parsed.filter((entry): entry is string => typeof entry === "string") - : []; - } catch { - return []; - } -} - -function parseAllowedCombos(value: unknown): string[] { - return parseStringList(value); -} - -function parseNoLog(value: unknown): boolean { - return value === true || value === 1 || value === "1"; -} - -function parseAutoResolve(value: unknown): boolean { - return value === true || value === 1 || value === "1"; -} - -function parseDisableNonPublicModels(value: unknown): boolean { - return value === true || value === 1 || value === "1"; -} - -function parseAllowUsageCommand(value: unknown): boolean { - return value === true || value === 1 || value === "1"; -} - -function parseIsActive(value: unknown): boolean { - // DEFAULT 1 — active unless explicitly set to 0 - if (value === 0 || value === "0" || value === false) return false; - return true; -} - -function parseAccessSchedule(value: unknown): AccessSchedule | null { - if (!value || typeof value !== "string" || value.trim() === "") return null; - try { - const parsed: unknown = JSON.parse(value); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - const obj = parsed as Record; - if ( - typeof obj["enabled"] !== "boolean" || - typeof obj["from"] !== "string" || - typeof obj["until"] !== "string" || - !Array.isArray(obj["days"]) || - typeof obj["tz"] !== "string" - ) { - return null; - } - const days = (obj["days"] as unknown[]).filter( - (d): d is number => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6 - ); - return { - enabled: obj["enabled"], - from: obj["from"], - until: obj["until"], - days, - tz: obj["tz"], - }; - } catch { - return null; - } -} - -function parseRateLimits(value: unknown): RateLimitRule[] | null { - if (!value || typeof value !== "string" || value.trim() === "") return null; - try { - const parsed = JSON.parse(value); - if (!Array.isArray(parsed)) return null; - return parsed.filter( - (rule: RateLimitRule) => - typeof rule === "object" && - rule !== null && - typeof rule.limit === "number" && - typeof rule.window === "number" - ) as RateLimitRule[]; - } catch { - return null; - } -} - -/** - * Helper function to safely parse allowed_connections JSON - */ -function parseAllowedConnections(value: unknown): string[] { - if (!value || typeof value !== "string" || value.trim() === "") { - return []; - } - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) - ? parsed.filter((entry): entry is string => typeof entry === "string") - : []; - } catch { - return []; - } -} - -/** - * Helper function to safely parse allowed_quotas JSON - */ -function parseAllowedQuotas(value: unknown): string[] { - if (!value || typeof value !== "string" || value.trim() === "") { - return []; - } - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) - ? parsed.filter((entry): entry is string => typeof entry === "string") - : []; - } catch { - return []; - } -} - -function parseStringList(value: unknown): string[] { - if (!value || typeof value !== "string" || value.trim() === "") return []; - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) - ? parsed.filter((entry): entry is string => typeof entry === "string") - : []; - } catch { - return []; - } -} - -function parseNullableTimestamp(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed === "" ? null : trimmed; -} - -function parseIsBanned(value: unknown): boolean { - return value === 1 || value === "1" || value === true; -} - -function parseStreamDefaultMode(value: unknown): "legacy" | "json" { - return value === "json" ? "json" : "legacy"; -} - async function hashKey(key: string): Promise { if (!key || typeof key !== "string") return ""; // CodeQL: This is intentionally SHA-256, NOT password hashing. API keys are diff --git a/src/lib/db/apiKeys/rowParsers.ts b/src/lib/db/apiKeys/rowParsers.ts new file mode 100644 index 0000000000..d087563b7e --- /dev/null +++ b/src/lib/db/apiKeys/rowParsers.ts @@ -0,0 +1,161 @@ +/** + * db/apiKeys/rowParsers.ts — pure column parsers for persisted api_keys rows. + * + * Extracted verbatim from db/apiKeys.ts (god-file decomposition): the family of + * functions that coerce raw SQLite column values (JSON strings, 0/1 flags, nullable + * timestamps) into the typed shapes the host hydrates rows with. Pure — no DB handle, + * no caches, no side effects — so they live as a co-located leaf. Behavior-preserving + * move; apiKeys.ts imports them back for getApiKeys/getApiKeyById/getApiKeyMetadata. + */ + +import type { AccessSchedule, RateLimitRule } from "./types"; + +/** + * Helper function to safely parse allowed_models JSON + */ +export function parseAllowedModels(value: unknown): string[] { + if (!value || typeof value !== "string" || value.trim() === "") { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : []; + } catch { + return []; + } +} + +export function parseAllowedCombos(value: unknown): string[] { + return parseStringList(value); +} + +export function parseNoLog(value: unknown): boolean { + return value === true || value === 1 || value === "1"; +} + +export function parseAutoResolve(value: unknown): boolean { + return value === true || value === 1 || value === "1"; +} + +export function parseDisableNonPublicModels(value: unknown): boolean { + return value === true || value === 1 || value === "1"; +} + +export function parseAllowUsageCommand(value: unknown): boolean { + return value === true || value === 1 || value === "1"; +} + +export function parseIsActive(value: unknown): boolean { + // DEFAULT 1 — active unless explicitly set to 0 + if (value === 0 || value === "0" || value === false) return false; + return true; +} + +export function parseAccessSchedule(value: unknown): AccessSchedule | null { + if (!value || typeof value !== "string" || value.trim() === "") return null; + try { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const obj = parsed as Record; + if ( + typeof obj["enabled"] !== "boolean" || + typeof obj["from"] !== "string" || + typeof obj["until"] !== "string" || + !Array.isArray(obj["days"]) || + typeof obj["tz"] !== "string" + ) { + return null; + } + const days = (obj["days"] as unknown[]).filter( + (d): d is number => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6 + ); + return { + enabled: obj["enabled"], + from: obj["from"], + until: obj["until"], + days, + tz: obj["tz"], + }; + } catch { + return null; + } +} + +export function parseRateLimits(value: unknown): RateLimitRule[] | null { + if (!value || typeof value !== "string" || value.trim() === "") return null; + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (rule: RateLimitRule) => + typeof rule === "object" && + rule !== null && + typeof rule.limit === "number" && + typeof rule.window === "number" + ) as RateLimitRule[]; + } catch { + return null; + } +} + +/** + * Helper function to safely parse allowed_connections JSON + */ +export function parseAllowedConnections(value: unknown): string[] { + if (!value || typeof value !== "string" || value.trim() === "") { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : []; + } catch { + return []; + } +} + +/** + * Helper function to safely parse allowed_quotas JSON + */ +export function parseAllowedQuotas(value: unknown): string[] { + if (!value || typeof value !== "string" || value.trim() === "") { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : []; + } catch { + return []; + } +} + +export function parseStringList(value: unknown): string[] { + if (!value || typeof value !== "string" || value.trim() === "") return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : []; + } catch { + return []; + } +} + +export function parseNullableTimestamp(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} + +export function parseIsBanned(value: unknown): boolean { + return value === 1 || value === "1" || value === true; +} + +export function parseStreamDefaultMode(value: unknown): "legacy" | "json" { + return value === "json" ? "json" : "legacy"; +} diff --git a/src/lib/db/apiKeys/types.ts b/src/lib/db/apiKeys/types.ts new file mode 100644 index 0000000000..edcda074c6 --- /dev/null +++ b/src/lib/db/apiKeys/types.ts @@ -0,0 +1,21 @@ +/** + * db/apiKeys/types.ts — shared API-key value types. + * + * Extracted from db/apiKeys.ts (god-file decomposition): the persisted-row shapes + * that both the host module and the row-parser leaf need. Kept as a neutral leaf so + * apiKeys.ts and apiKeys/rowParsers.ts can import them without a cycle. apiKeys.ts + * re-exports both interfaces to preserve its historical public surface. + */ + +export interface RateLimitRule { + limit: number; + window: number; +} + +export interface AccessSchedule { + enabled: boolean; + from: string; + until: string; + days: number[]; + tz: string; +} diff --git a/tests/unit/apikeys-row-parsers-split.test.ts b/tests/unit/apikeys-row-parsers-split.test.ts new file mode 100644 index 0000000000..3788f220be --- /dev/null +++ b/tests/unit/apikeys-row-parsers-split.test.ts @@ -0,0 +1,90 @@ +// Characterization of the db/apiKeys.ts row-parsers split (god-file decomposition): the pure column +// parsers that coerce raw SQLite values into typed shapes moved into db/apiKeys/rowParsers.ts, and the +// two shared row types (AccessSchedule / RateLimitRule) into db/apiKeys/types.ts. Behavior-preserving +// move — these locks pin the coercion semantics (JSON lists, 0/1 flags, nullable timestamps, schedule +// validation) and that the host still re-exports the two public types. DB-backed hydration stays +// covered by api-key-policy / combo-provider-wildcard. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const P = await import("../../src/lib/db/apiKeys/rowParsers.ts"); + +test("module exposes all fifteen parsers", () => { + for (const name of [ + "parseAllowedModels", + "parseAllowedCombos", + "parseNoLog", + "parseAutoResolve", + "parseDisableNonPublicModels", + "parseAllowUsageCommand", + "parseIsActive", + "parseAccessSchedule", + "parseRateLimits", + "parseAllowedConnections", + "parseAllowedQuotas", + "parseStringList", + "parseNullableTimestamp", + "parseIsBanned", + "parseStreamDefaultMode", + ]) { + assert.equal(typeof (P as Record)[name], "function", `missing ${name}`); + } +}); + +test("parseAllowedModels keeps only string entries, tolerates junk", () => { + assert.deepEqual(P.parseAllowedModels('["a","b",1,null]'), ["a", "b"]); + assert.deepEqual(P.parseAllowedModels(""), []); + assert.deepEqual(P.parseAllowedModels("not json"), []); + assert.deepEqual(P.parseAllowedModels(null), []); +}); + +test("flag parsers honor the 0/1/true/false matrix", () => { + assert.equal(P.parseNoLog(1), true); + assert.equal(P.parseNoLog("1"), true); + assert.equal(P.parseNoLog(0), false); + // isActive defaults to active unless explicitly falsy + assert.equal(P.parseIsActive(undefined), true); + assert.equal(P.parseIsActive(0), false); + assert.equal(P.parseIsActive("0"), false); + assert.equal(P.parseIsBanned(1), true); + assert.equal(P.parseIsBanned(0), false); +}); + +test("parseStreamDefaultMode collapses to legacy unless json", () => { + assert.equal(P.parseStreamDefaultMode("json"), "json"); + assert.equal(P.parseStreamDefaultMode("legacy"), "legacy"); + assert.equal(P.parseStreamDefaultMode("anything"), "legacy"); +}); + +test("parseNullableTimestamp trims and nulls empties", () => { + assert.equal(P.parseNullableTimestamp(" 2026-01-01 "), "2026-01-01"); + assert.equal(P.parseNullableTimestamp(" "), null); + assert.equal(P.parseNullableTimestamp(42), null); +}); + +test("parseAccessSchedule validates shape + clamps days, else null", () => { + const ok = P.parseAccessSchedule( + JSON.stringify({ enabled: true, from: "08:00", until: "18:00", days: [0, 3, 9], tz: "UTC" }) + ); + assert.ok(ok); + assert.deepEqual(ok?.days, [0, 3]); // 9 dropped (out of 0..6) + assert.equal(P.parseAccessSchedule('{"enabled":true}'), null); + assert.equal(P.parseAccessSchedule("[]"), null); + assert.equal(P.parseAccessSchedule(""), null); +}); + +test("parseRateLimits keeps well-formed numeric rules only", () => { + const out = P.parseRateLimits( + JSON.stringify([{ limit: 10, window: 60 }, { limit: "x", window: 1 }, null]) + ); + assert.deepEqual(out, [{ limit: 10, window: 60 }]); + assert.equal(P.parseRateLimits("not array"), null); + assert.equal(P.parseRateLimits(""), null); +}); + +test("host re-exports the two public row types (compile-time) and the parsers stay wired", async () => { + // type-only re-export can't be probed at runtime; assert the host module still loads + exposes its API + const HOST = await import("../../src/lib/db/apiKeys.ts"); + assert.equal(typeof (HOST as Record).getApiKeys, "function"); + assert.equal(typeof (HOST as Record).isModelAllowedForKey, "function"); +});