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:
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|||||||
import { getCookie } from "hono/cookie";
|
import { getCookie } from "hono/cookie";
|
||||||
import type { Db } from "./db";
|
import type { Db } from "./db";
|
||||||
import { authRoutes, isAuthenticated } from "./routes/auth";
|
import { authRoutes, isAuthenticated } from "./routes/auth";
|
||||||
|
import { settingsRoutes } from "./routes/settings";
|
||||||
|
|
||||||
export type Deps = { db: Db; key: Buffer };
|
export type Deps = { db: Db; key: Buffer };
|
||||||
const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]);
|
const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]);
|
||||||
@@ -17,7 +18,7 @@ export function createApp(deps: Deps) {
|
|||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
app.route("/api", authRoutes({ db: deps.db }));
|
app.route("/api", authRoutes({ db: deps.db }));
|
||||||
// Later route groups (settings, connectors, chat) mount here.
|
app.route("/api", settingsRoutes(deps));
|
||||||
app.get("/api/settings", (c) => c.json({ error: "not implemented" }, 501)); // replaced in Task 5
|
// Later route groups (connectors, chat) mount here.
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
54
server/src/routes/settings.ts
Normal file
54
server/src/routes/settings.ts
Normal 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;
|
||||||
|
}
|
||||||
58
server/test/settings.test.ts
Normal file
58
server/test/settings.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { createApp } from "../src/app";
|
||||||
|
import { openDb } from "../src/db";
|
||||||
|
import { settings } from "../src/db/schema";
|
||||||
|
import { loadOrCreateKey } from "../src/lib/crypto";
|
||||||
|
|
||||||
|
async function authedApp() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
|
const db = openDb(dir);
|
||||||
|
const app = createApp({ db, key: loadOrCreateKey(dir) });
|
||||||
|
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
||||||
|
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
|
||||||
|
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
|
||||||
|
return { app, db, cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
test("defaults, update, masked key, encrypted at rest", async () => {
|
||||||
|
const { app, db, cookie } = await authedApp();
|
||||||
|
const h = { cookie };
|
||||||
|
|
||||||
|
let s = await (await app.request("/api/settings", { headers: h })).json();
|
||||||
|
expect(s).toEqual({
|
||||||
|
llmBaseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
llmModel: "anthropic/claude-sonnet-4.5",
|
||||||
|
llmKeyMasked: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const put = await app.request("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...h, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ llmKey: "sk-or-v1-supersecret-abcd", llmModel: "meta-llama/llama-4" }),
|
||||||
|
});
|
||||||
|
expect(put.status).toBe(204);
|
||||||
|
|
||||||
|
s = await (await app.request("/api/settings", { headers: h })).json();
|
||||||
|
expect(s.llmKeyMasked).toBe("…abcd");
|
||||||
|
expect(s.llmModel).toBe("meta-llama/llama-4");
|
||||||
|
|
||||||
|
const raw = (await db.select().from(settings).where(eq(settings.key, "llm_key"))).at(0)!.value;
|
||||||
|
expect(raw.startsWith("enc:")).toBe(true);
|
||||||
|
expect(raw).not.toContain("supersecret");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects bad body", async () => {
|
||||||
|
const { app, cookie } = await authedApp();
|
||||||
|
const r = await app.request("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { cookie, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ llmBaseUrl: "not a url" }),
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,3 +6,17 @@ export type HealthResponse = z.infer<typeof HealthResponse>;
|
|||||||
export const PasswordBody = z.object({ password: z.string().min(8).max(200) });
|
export const PasswordBody = z.object({ password: z.string().min(8).max(200) });
|
||||||
export const MeResponse = z.object({ needsSetup: z.boolean(), authenticated: z.boolean() });
|
export const MeResponse = z.object({ needsSetup: z.boolean(), authenticated: z.boolean() });
|
||||||
export type MeResponse = z.infer<typeof MeResponse>;
|
export type MeResponse = z.infer<typeof MeResponse>;
|
||||||
|
|
||||||
|
export const SettingsResponse = z.object({
|
||||||
|
llmBaseUrl: z.string(),
|
||||||
|
llmModel: z.string(),
|
||||||
|
llmKeyMasked: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type SettingsResponse = z.infer<typeof SettingsResponse>;
|
||||||
|
|
||||||
|
export const SettingsUpdate = z.object({
|
||||||
|
llmBaseUrl: z.string().url().optional(),
|
||||||
|
llmModel: z.string().min(1).optional(),
|
||||||
|
llmKey: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
export type SettingsUpdate = z.infer<typeof SettingsUpdate>;
|
||||||
|
|||||||
Reference in New Issue
Block a user