Skip to content

Commit 4a0bd75

Browse files
authored
fix(settings): persist only the billing delta so resolved secrets never hit disk (#60)
The Spend toggle PUT round-tripped Config.getGlobal() into Config.updateGlobal(). getGlobal() returns the resolved config, with {env:VAR} and {file:...} references substituted, so flipping a toggle rewrote openscience.json with plaintext secrets and destroyed the env indirection. updateGlobal already deep-merges into the raw file, so the route only needs to pass the billing delta.
1 parent fb842b4 commit 4a0bd75

2 files changed

Lines changed: 40 additions & 3 deletions

File tree

backend/cli/src/server/routes/settings/billing.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,10 @@ export const BillingSettingsRoutes = lazy(() =>
6969
validator("json", BillingPatch),
7070
async (c) => {
7171
const patch = c.req.valid("json")
72-
const cfg = await Config.getGlobal()
73-
const next = { ...cfg, billing: { ...(cfg.billing ?? {}), ...patch } }
74-
await Config.updateGlobal(next)
72+
// Persist only the delta. updateGlobal deep-merges into the raw file;
73+
// writing back Config.getGlobal() would bake resolved {env:}/{file:}
74+
// secrets into openscience.json in plaintext.
75+
await Config.updateGlobal({ billing: patch })
7576
log.info("update", { keys: Object.keys(patch) })
7677

7778
// Mirror the LLM toggle to the account-scoped server billing mode, then force
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { test, expect, afterEach } from "bun:test"
2+
import path from "path"
3+
import fs from "fs/promises"
4+
import { Global } from "../../src/global"
5+
import { BillingSettingsRoutes } from "../../src/server/routes/settings/billing"
6+
7+
const file = path.join(Global.Path.config, "openscience.json")
8+
9+
afterEach(async () => {
10+
await fs.rm(file, { force: true }).catch(() => {})
11+
})
12+
13+
test("PUT persists the toggle without baking resolved secrets into the config file", async () => {
14+
process.env["SPEND_TOGGLE_TEST_KEY"] = "sk-live-super-secret-123"
15+
await fs.mkdir(Global.Path.config, { recursive: true })
16+
await Bun.write(
17+
file,
18+
JSON.stringify({ provider: { openrouter: { options: { apiKey: "{env:SPEND_TOGGLE_TEST_KEY}" } } } }, null, 2),
19+
)
20+
21+
const res = await BillingSettingsRoutes().request("/", {
22+
method: "PUT",
23+
headers: { "content-type": "application/json" },
24+
body: JSON.stringify({ llm: "byok" }),
25+
})
26+
expect(res.status).toBe(200)
27+
const state = await res.json()
28+
expect(state.llm).toBe("byok")
29+
30+
const text = await Bun.file(file).text()
31+
expect(text).toContain("{env:SPEND_TOGGLE_TEST_KEY}")
32+
expect(text).not.toContain("sk-live-super-secret-123")
33+
34+
const written = JSON.parse(text)
35+
expect(written.billing).toEqual({ llm: "byok" })
36+
})

0 commit comments

Comments
 (0)