Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/14060-malformed-sqlite-500.md
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions src/app/(dashboard)/home/loadHomeSettings.ts
Original file line number Diff line number Diff line change
@@ -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<HomeSettings> = getSettings
): Promise<HomeSettings> {
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 };
}
}
7 changes: 3 additions & 4 deletions src/app/(dashboard)/home/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<>
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/settings-14060-getsettings-corrupt-db.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading