diff --git a/server/src/app.ts b/server/src/app.ts index 7e7c1ce..4f2cb51 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; } diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts new file mode 100644 index 0000000..081abb7 --- /dev/null +++ b/server/src/routes/settings.ts @@ -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 { + return (await db.select().from(settings).where(eq(settings.key, key))).at(0)?.value; +} + +async function put(db: Db, key: string, value: string): Promise { + 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 { + 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; +} diff --git a/server/test/settings.test.ts b/server/test/settings.test.ts new file mode 100644 index 0000000..56832fb --- /dev/null +++ b/server/test/settings.test.ts @@ -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); + }); +}); diff --git a/shared/src/types.ts b/shared/src/types.ts index c0b2eae..1557441 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -6,3 +6,17 @@ export type HealthResponse = z.infer; export const PasswordBody = z.object({ password: z.string().min(8).max(200) }); export const MeResponse = z.object({ needsSetup: z.boolean(), authenticated: z.boolean() }); export type MeResponse = z.infer; + +export const SettingsResponse = z.object({ + llmBaseUrl: z.string(), + llmModel: z.string(), + llmKeyMasked: z.string().nullable(), +}); +export type SettingsResponse = z.infer; + +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;