feat(settings): LLM config API with AES-GCM sealed key, masked reads

- Add GET/PUT /api/settings for encrypted LLM configuration
- SettingsResponse with masked key display
- SettingsUpdate validation with optional fields
- Defaults: llmBaseUrl "https://openrouter.ai/api/v1", llmModel "anthropic/claude-sonnet-4.5"
- Encrypted storage with AES-256-GCM for llm_key
- getSetting() export for future route groups
- Comprehensive test coverage: defaults, updates, masking, encryption at rest, validation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
marcuspaico
2026-08-17 14:14:53 -07:00
parent 0b8e897e31
commit f33d395b71
4 changed files with 129 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import type { Db } from "./db";
import { authRoutes, isAuthenticated } from "./routes/auth";
import { settingsRoutes } from "./routes/settings";
export type Deps = { db: Db; key: Buffer };
const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]);
@@ -17,7 +18,7 @@ export function createApp(deps: Deps) {
return next();
});
app.route("/api", authRoutes({ db: deps.db }));
// Later route groups (settings, connectors, chat) mount here.
app.get("/api/settings", (c) => c.json({ error: "not implemented" }, 501)); // replaced in Task 5
app.route("/api", settingsRoutes(deps));
// Later route groups (connectors, chat) mount here.
return app;
}

View File

@@ -0,0 +1,54 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { SettingsUpdate } from "@helios/shared";
import type { Db } from "../db";
import { settings } from "../db/schema";
import { decrypt, encrypt, mask } from "../lib/crypto";
const DEFAULTS = {
llm_base_url: "https://openrouter.ai/api/v1",
llm_model: "anthropic/claude-sonnet-4.5",
} as const;
async function get(db: Db, key: string): Promise<string | undefined> {
return (await db.select().from(settings).where(eq(settings.key, key))).at(0)?.value;
}
async function put(db: Db, key: string, value: string): Promise<void> {
await db.insert(settings).values({ key, value }).onConflictDoUpdate({ target: settings.key, set: { value } });
}
/** Decrypted read for other route groups (chat, connectors). */
export async function getSetting(db: Db, key: string, cryptoKey: Buffer): Promise<string | undefined> {
const v = await get(db, key);
return v?.startsWith("enc:") ? decrypt(cryptoKey, v) : v;
}
export function settingsRoutes(deps: { db: Db; key: Buffer }) {
const app = new Hono();
app.get("/settings", async (c) => {
const [base, model, sealed] = await Promise.all([
get(deps.db, "llm_base_url"),
get(deps.db, "llm_model"),
get(deps.db, "llm_key"),
]);
return c.json({
llmBaseUrl: base ?? DEFAULTS.llm_base_url,
llmModel: model ?? DEFAULTS.llm_model,
llmKeyMasked: sealed ? mask(decrypt(deps.key, sealed)) : null,
});
});
app.put("/settings", async (c) => {
const body = SettingsUpdate.safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: body.error.issues[0]?.message ?? "invalid body" }, 400);
const { llmBaseUrl, llmModel, llmKey } = body.data;
if (llmBaseUrl) await put(deps.db, "llm_base_url", llmBaseUrl);
if (llmModel) await put(deps.db, "llm_model", llmModel);
if (llmKey) await put(deps.db, "llm_key", encrypt(deps.key, llmKey));
return c.body(null, 204);
});
return app;
}