From 7064cca334189ab5492bd8bdfe1647d6ae3783d7 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:44:21 -0300 Subject: [PATCH 1/2] fix(db): degrade getSettings() to defaults instead of crashing Home on corrupted SQLite (#14060) Root cause: getSettings() (src/lib/db/settings.ts) read the key_value table without try/catch, and src/app/(dashboard)/home/page.tsx awaited it unguarded during server-side rendering. When the key_value table's page is corrupted (SQLITE_CORRUPT / 'database disk image is malformed'), that unguarded read threw synchronously and crashed the Home Server Component render, producing the generic Next.js Internal Server Error the reporter saw. Fix: getSettings() now catches the read error, warns via console.warn (mirroring the pattern already used by optimizationSettings.ts, proxyLogger.ts and memory/index.ts), and falls through to the existing in-memory defaults. The Home page also wraps getSettings() in a .catch() as defense-in-depth against a future unguarded read anywhere in its dependency chain. Regression test: tests/unit/settings-14060-getsettings-corrupt-db.test.ts corrupts only the on-disk page backing key_value on an otherwise-valid storage.sqlite, then asserts getSettings() degrades to defaults instead of throwing. --- .../fixes/14060-malformed-sqlite-500.md | 1 + src/app/(dashboard)/home/page.tsx | 12 +++- src/lib/db/settings.ts | 8 ++- ...tings-14060-getsettings-corrupt-db.test.ts | 64 +++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/14060-malformed-sqlite-500.md create mode 100644 tests/unit/settings-14060-getsettings-corrupt-db.test.ts 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..fbd2edff4b5e --- /dev/null +++ b/changelog.d/fixes/14060-malformed-sqlite-500.md @@ -0,0 +1 @@ +- fix(db): degrade getSettings() to defaults instead of crashing the Home dashboard when the key_value table is corrupted (#14060) diff --git a/src/app/(dashboard)/home/page.tsx b/src/app/(dashboard)/home/page.tsx index 7580d67c1699..228a97f79255 100644 --- a/src/app/(dashboard)/home/page.tsx +++ b/src/app/(dashboard)/home/page.tsx @@ -13,7 +13,17 @@ 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()]); + // Defense-in-depth (#14060): getSettings() already degrades to defaults on a corrupted + // key_value table, but a future unguarded read anywhere in its dependency chain should + // not be able to crash this Server Component render again. + const [settings, machineId] = await Promise.all([ + getSettings().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[Home] Failed to load settings; using defaults: ${message}`); + return { setupComplete: false }; + }), + getMachineId(), + ]); const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true"; return ( <> diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 448c38e6d7a9..c549c26980cc 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -151,7 +151,13 @@ function applySessionAffinityLegacyFallback(settings: Record): export async function getSettings() { const db = getDbInstance(); - const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all(); + let rows: unknown[] = []; + try { + rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[DB] Failed to read settings; using defaults: ${message}`); + } const settings: Record = { cloudEnabled: true, tailscaleEnabled: false, 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..3df6fb467195 --- /dev/null +++ b/tests/unit/settings-14060-getsettings-corrupt-db.test.ts @@ -0,0 +1,64 @@ +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 core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("getSettings() degrades to defaults (does not throw) when only the key_value table's page is corrupted", async () => { + 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"); + + db.prepare( + "INSERT INTO key_value (namespace, key, value) VALUES ('settings', 'requireLogin', 'true')" + ).run(); + + 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"); + const rootPage = rootPageRow!.rootpage; + + core.resetDbInstance(); + + const buf = fs.readFileSync(dbPath as string); + const pageStart = (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(); + + const settings = await settingsDb.getSettings(); + + assert.equal( + typeof settings, + "object", + "expected getSettings() to degrade to a defaults object instead of throwing" + ); + assert.equal( + settings.requireLogin, + true, + "expected the built-in default for requireLogin (row could not be read from the corrupted table)" + ); +}); From 9f821343553cb70e36f5a67bd7bee29586fc39d7 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:55:02 -0300 Subject: [PATCH 2/2] fix(db): keep getSettings() fail-closed; degrade only the Home consumer (#14060) Swallowing the key_value read error inside getSettings() returned password-less defaults, so isAuthRequired() stopped failing closed and disabled auth for loopback requests (including the first-password bootstrap write). Revert that and move the degradation into a display-only loadHomeSettings() helper used by the Home page. Regression test proves isAuthRequired() stays true on a corrupted key_value table and that Home degrades without throwing. --- .../fixes/14060-malformed-sqlite-500.md | 2 +- src/app/(dashboard)/home/loadHomeSettings.ts | 25 +++++++++ src/app/(dashboard)/home/page.tsx | 17 ++----- src/lib/db/settings.ts | 8 +-- ...tings-14060-getsettings-corrupt-db.test.ts | 51 +++++++++++++------ 5 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 src/app/(dashboard)/home/loadHomeSettings.ts diff --git a/changelog.d/fixes/14060-malformed-sqlite-500.md b/changelog.d/fixes/14060-malformed-sqlite-500.md index fbd2edff4b5e..a8a94213450d 100644 --- a/changelog.d/fixes/14060-malformed-sqlite-500.md +++ b/changelog.d/fixes/14060-malformed-sqlite-500.md @@ -1 +1 @@ -- fix(db): degrade getSettings() to defaults instead of crashing the Home dashboard when the key_value table is corrupted (#14060) +- 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 228a97f79255..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,19 +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. - // Defense-in-depth (#14060): getSettings() already degrades to defaults on a corrupted - // key_value table, but a future unguarded read anywhere in its dependency chain should - // not be able to crash this Server Component render again. - const [settings, machineId] = await Promise.all([ - getSettings().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[Home] Failed to load settings; using defaults: ${message}`); - return { setupComplete: false }; - }), - 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/src/lib/db/settings.ts b/src/lib/db/settings.ts index c549c26980cc..448c38e6d7a9 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -151,13 +151,7 @@ function applySessionAffinityLegacyFallback(settings: Record): export async function getSettings() { const db = getDbInstance(); - let rows: unknown[] = []; - try { - rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[DB] Failed to read settings; using defaults: ${message}`); - } + const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all(); const settings: Record = { cloudEnabled: true, tailscaleEnabled: false, diff --git a/tests/unit/settings-14060-getsettings-corrupt-db.test.ts b/tests/unit/settings-14060-getsettings-corrupt-db.test.ts index 3df6fb467195..1e6a5b187b87 100644 --- a/tests/unit/settings-14060-getsettings-corrupt-db.test.ts +++ b/tests/unit/settings-14060-getsettings-corrupt-db.test.ts @@ -6,35 +6,37 @@ 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 }); }); -test("getSettings() degrades to defaults (does not throw) when only the key_value table's page is corrupted", async () => { +/** 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"); - db.prepare( - "INSERT INTO key_value (namespace, key, value) VALUES ('settings', 'requireLogin', 'true')" - ).run(); - 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"); - const rootPage = rootPageRow!.rootpage; core.resetDbInstance(); const buf = fs.readFileSync(dbPath as string); - const pageStart = (rootPage - 1) * pageSize; + const pageStart = (rootPageRow!.rootpage - 1) * pageSize; for (let i = pageStart; i < Math.min(buf.length, pageStart + pageSize); i++) { buf[i] = 0xff; } @@ -48,17 +50,36 @@ test("getSettings() degrades to defaults (does not throw) when only the key_valu .get(); assert.equal(bootHealthy, true, "expected the boot probe's own tables to remain readable"); core.resetDbInstance(); +} - const settings = await settingsDb.getSettings(); +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( - typeof settings, - "object", - "expected getSettings() to degrade to a defaults object instead of throwing" - ); - assert.equal( - settings.requireLogin, + await apiAuth.isAuthRequired(loopbackRequest, { loopback: true }), true, - "expected the built-in default for requireLogin (row could not be read from the corrupted table)" + "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 }); });