diff --git a/changelog.d/fixes/14060-malformed-sqlite-500.md b/changelog.d/fixes/14060-malformed-sqlite-500.md new file mode 100644 index 000000000000..a8a94213450d --- /dev/null +++ b/changelog.d/fixes/14060-malformed-sqlite-500.md @@ -0,0 +1 @@ +- fix(dashboard): Home degrades to default settings instead of returning 500 when the SQLite key_value table is corrupted; getSettings() keeps failing closed so auth gates still require login (#14060) diff --git a/src/app/(dashboard)/home/loadHomeSettings.ts b/src/app/(dashboard)/home/loadHomeSettings.ts new file mode 100644 index 000000000000..d083cb2e0185 --- /dev/null +++ b/src/app/(dashboard)/home/loadHomeSettings.ts @@ -0,0 +1,25 @@ +import { getSettings } from "@/lib/db/settings"; + +export type HomeSettings = { setupComplete?: unknown }; + +/** + * Settings read for the Home Server Component (#14060). + * + * A corrupted `key_value` table makes getSettings() throw, which used to crash the + * whole Home render with a 500. The degradation lives HERE, in the display-only + * consumer — never inside getSettings() itself: auth/authz callers such as + * isAuthRequired() rely on getSettings() rejecting so they fail CLOSED. Swallowing + * the error at the DB layer would hand them password-less defaults and disable + * auth for loopback requests. + */ +export async function loadHomeSettings( + load: () => Promise = getSettings +): Promise { + try { + return await load(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[Home] Failed to load settings; rendering with defaults: ${message}`); + return { setupComplete: false }; + } +} diff --git a/src/app/(dashboard)/home/page.tsx b/src/app/(dashboard)/home/page.tsx index 7580d67c1699..99e4f71a540a 100644 --- a/src/app/(dashboard)/home/page.tsx +++ b/src/app/(dashboard)/home/page.tsx @@ -1,5 +1,5 @@ import { getMachineId } from "@/shared/utils/machine"; -import { getSettings } from "@/lib/db/settings"; +import { loadHomeSettings } from "./loadHomeSettings"; import HomePageClient from "../dashboard/HomePageClient"; import BootstrapBanner from "../dashboard/BootstrapBanner"; import KimiSponsorBanner from "../dashboard/KimiSponsorBanner"; @@ -11,9 +11,8 @@ import FirstRunReadinessCard from "../dashboard/FirstRunReadinessCard"; export const dynamic = "force-dynamic"; export default async function HomePage() { - // Even if getSettings() rejects, getMachineId() runs concurrently, which is acceptable - // as both paths fail-fast on error and avoids the waterfall penalty. - const [settings, machineId] = await Promise.all([getSettings(), getMachineId()]); + // Settings failures degrade to defaults here (display-only) — see loadHomeSettings (#14060). + const [settings, machineId] = await Promise.all([loadHomeSettings(), getMachineId()]); const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true"; return ( <> diff --git a/tests/unit/settings-14060-getsettings-corrupt-db.test.ts b/tests/unit/settings-14060-getsettings-corrupt-db.test.ts new file mode 100644 index 000000000000..1e6a5b187b87 --- /dev/null +++ b/tests/unit/settings-14060-getsettings-corrupt-db.test.ts @@ -0,0 +1,85 @@ +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-issue-14060-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +delete process.env.INITIAL_PASSWORD; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiAuth = await import("../../src/shared/utils/apiAuth.ts"); +const { loadHomeSettings } = await import("../../src/app/(dashboard)/home/loadHomeSettings.ts"); + +test.after(() => { + core.resetDbInstance(); + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +/** Overwrite only the key_value table's root page so the boot probe tables stay readable. */ +function corruptKeyValueTable() { + const db = core.getDbInstance(); + const dbPath = (db as { name?: string }).name; + assert.ok(typeof dbPath === "string" && dbPath.length > 0, "expected a file-backed db path"); + + const pageSize = (db.pragma("page_size") as Array<{ page_size: number }>)[0].page_size; + const rootPageRow = db + .prepare("SELECT rootpage FROM sqlite_master WHERE type = 'table' AND name = 'key_value'") + .get() as { rootpage: number } | undefined; + assert.ok(rootPageRow?.rootpage, "expected to find key_value's rootpage in sqlite_master"); + + core.resetDbInstance(); + + const buf = fs.readFileSync(dbPath as string); + const pageStart = (rootPageRow!.rootpage - 1) * pageSize; + for (let i = pageStart; i < Math.min(buf.length, pageStart + pageSize); i++) { + buf[i] = 0xff; + } + fs.writeFileSync(dbPath as string, buf); + + const reopened = core.getDbInstance(); + const bootHealthy = !!reopened + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'provider_connections'" + ) + .get(); + assert.equal(bootHealthy, true, "expected the boot probe's own tables to remain readable"); + core.resetDbInstance(); +} + +test("#14060: corrupted key_value keeps auth fail-closed and Home degrades without crashing", async () => { + corruptKeyValueTable(); + + // (a) Auth gate stays fail-closed: a loopback request on a protected path, with no + // password configured, must still require auth when settings cannot be read. + // Swallowing the error inside getSettings() returned password-less defaults and + // made this `false` (auth disabled for loopback). + const loopbackRequest = new Request("http://localhost:20128/api/providers"); + assert.equal( + await apiAuth.isAuthRequired(loopbackRequest, { loopback: true }), + true, + "isAuthRequired() must fail closed when settings are unreadable" + ); + // The first-password bootstrap write must not open up either. + const bootstrapWrite = new Request("http://localhost:20128/api/settings/require-login", { + method: "POST", + }); + assert.equal(await apiAuth.isAuthRequired(bootstrapWrite, { loopback: true }), true); + + // getSettings() must keep surfacing the read error — its auth callers depend on it. + await assert.rejects(() => settingsDb.getSettings()); + + // (b) Home degrades to defaults instead of rejecting (the original 500). + const homeSettings = await loadHomeSettings(); + assert.deepEqual(homeSettings, { setupComplete: false }); +}); + +test("loadHomeSettings passes healthy settings through untouched", async () => { + const result = await loadHomeSettings(async () => ({ setupComplete: true })); + assert.deepEqual(result, { setupComplete: true }); +});