feat: add config API with encrypted secret storage

This commit is contained in:
Jonathan
2026-07-10 19:54:27 +02:00
parent 91ea32c4b8
commit 1b11fa37bd
3 changed files with 89 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeAll } from "vitest";
import { GET, PUT } from "./route";
import { prisma } from "@/lib/db";
beforeAll(() => {
process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
});
function putReq(body: unknown) {
return new Request("http://localhost/api/config", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("config API", () => {
it("stores plain fields verbatim and secret fields encrypted", async () => {
await PUT(putReq({ qobuzEmail: "me@example.com", qobuzPassword: "hunter2", slskdUrl: "http://slskd:5030", slskdApiKey: "abc" }));
const email = await prisma.config.findUnique({ where: { key: "qobuz.email" } });
const pw = await prisma.config.findUnique({ where: { key: "qobuz.password" } });
expect(email!.value).toBe("me@example.com");
expect(email!.secret).toBe(false);
expect(pw!.secret).toBe(true);
expect(pw!.value).not.toContain("hunter2"); // encrypted envelope
});
it("GET masks secrets and reports set-ness", async () => {
await PUT(putReq({ qobuzEmail: "me@example.com", qobuzPassword: "hunter2" }));
const res = await GET();
const body = await res.json();
expect(body.qobuzEmail).toBe("me@example.com");
expect(body.qobuzPasswordSet).toBe(true);
expect(body.slskdApiKeySet).toBe(false);
expect(JSON.stringify(body)).not.toContain("hunter2");
});
it("empty string leaves a field unchanged", async () => {
await PUT(putReq({ qobuzPassword: "first" }));
await PUT(putReq({ qobuzPassword: "" }));
const pw = await prisma.config.findUnique({ where: { key: "qobuz.password" } });
expect(pw).not.toBeNull(); // still present, not wiped
});
});
+43
View File
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/db";
import { encryptSecret } from "@/lib/crypto";
// settings field -> (config key, is secret)
const FIELDS: Record<string, { key: string; secret: boolean }> = {
qobuzEmail: { key: "qobuz.email", secret: false },
qobuzPassword: { key: "qobuz.password", secret: true },
slskdUrl: { key: "slskd.url", secret: false },
slskdApiKey: { key: "slskd.api_key", secret: true },
};
export async function PUT(request: Request) {
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
for (const [field, { key, secret }] of Object.entries(FIELDS)) {
const raw = body[field];
if (typeof raw !== "string" || raw === "") continue; // absent or "unchanged"
const value = secret ? encryptSecret(raw) : raw;
await prisma.config.upsert({
where: { key },
create: { key, value, secret },
update: { value, secret },
});
}
return Response.json({ ok: true });
}
export async function GET() {
const rows = await prisma.config.findMany();
const byKey = new Map(rows.map((r) => [r.key, r]));
return Response.json({
qobuzEmail: byKey.get("qobuz.email")?.value ?? "",
slskdUrl: byKey.get("slskd.url")?.value ?? "",
qobuzPasswordSet: byKey.has("qobuz.password"),
slskdApiKeySet: byKey.has("slskd.api_key"),
});
}
+1
View File
@@ -5,4 +5,5 @@ beforeEach(async () => {
// Job has a cascade FK to Request; delete Job first.
await prisma.job.deleteMany();
await prisma.request.deleteMany();
await prisma.config.deleteMany();
});