Files
Lyra/docs/superpowers/plans/2026-07-10-lyra-config-secrets.md
Jonathan 349f699bb6 docs: add configuration & secrets plan (slice 1, plan 3a)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:45:08 +02:00

707 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lyra Configuration & Secrets Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give Lyra an encrypted key/value configuration store so the upcoming real source adapters can read credentials (Qobuz login, slskd URL + API key) that the user enters through a settings page — secrets encrypted at rest and shared across the Node web app (which writes them) and the Python worker (which reads them).
**Architecture:** A Prisma-owned `Config` table holds `key → value` rows, with a `secret` flag marking values stored as an AES-256-GCM envelope. Both runtimes share one 32-byte key from the `LYRA_SECRET_KEY` env var and one envelope format (`{iv, ct, tag}`, all base64). The Next.js settings page posts credentials to `PUT /api/config`, which encrypts secret fields before upserting; `GET /api/config` never returns secret values, only whether each is set. The worker's `config.get_config(conn)` reads and decrypts everything for the adapters.
**Tech Stack:** Prisma 6 / PostgreSQL 17; Next.js App Router + Node `crypto` (AES-256-GCM); Python 3.12 + the `cryptography` package (`AESGCM`). Tests: Vitest (web), pytest (worker), including a cross-language test proving the Python worker decrypts a Node-produced envelope.
## Global Constraints
- Prisma owns the schema; the worker uses raw psycopg v3 SQL only (no DDL).
- **Crypto contract (both runtimes MUST match exactly):** AES-256-GCM. Key = 32 raw bytes decoded from base64 env var `LYRA_SECRET_KEY`. Nonce/IV = 12 random bytes per encryption. Envelope = a JSON string `{"iv": b64, "ct": b64, "tag": b64}` where `ct` is the ciphertext WITHOUT the tag and `tag` is the 16-byte GCM tag. (Node keeps the tag separate via `getAuthTag()`; Python's `AESGCM` appends the tag to ciphertext, so Python code must split/join the last 16 bytes — shown in the tasks.)
- Secrets NEVER leave the backend in plaintext: `GET /api/config` returns booleans (`*Set`) for secret fields, never their values.
- Config keys (fixed): `qobuz.email` (plain), `qobuz.password` (secret), `slskd.url` (plain), `slskd.api_key` (secret). Later adapter plans read these.
- `LYRA_SECRET_KEY` is provided to BOTH the `web` and `worker` services via environment.
- Every task ends with a commit.
## Shared interfaces (defined by tasks below; listed for consistency)
```
web/src/lib/crypto.ts (Task 2): encryptSecret(plaintext: string): string ; decryptSecret(envelope: string): string
web/src/app/api/config/route.ts (Task 3): GET -> masked config ; PUT (partial settings) -> upsert (encrypt secrets)
worker/lyra_worker/crypto.py (Task 5): encrypt_secret(plaintext: str) -> str ; decrypt_secret(envelope: str) -> str
worker/lyra_worker/config.py (Task 6): get_config(conn) -> dict[str, str] # secrets decrypted
```
---
### Task 1: Config schema + migration
**Files:**
- Modify: `web/prisma/schema.prisma`
**Interfaces:**
- Produces: a `Config` table — `key String @id`, `value String`, `secret Boolean @default(false)`, `updatedAt DateTime @updatedAt`.
- [ ] **Step 1: Add the model**
Append to `web/prisma/schema.prisma`:
```prisma
model Config {
key String @id
value String
secret Boolean @default(false)
updatedAt DateTime @updatedAt
}
```
- [ ] **Step 2: Create and apply the migration**
Run (Postgres up via `docker compose up -d db`):
```bash
cd web
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
npx prisma migrate dev --name add_config
```
Expected: migration created under `web/prisma/migrations/` and applied; "Your database is now in sync with your schema."
- [ ] **Step 3: Verify the table exists**
Run:
```bash
cd web
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
npx prisma db execute --stdin <<'SQL'
SELECT 1 FROM "Config" LIMIT 0;
SQL
echo "table OK"
```
Expected: no error; prints `table OK`.
- [ ] **Step 4: Commit**
```bash
git add web/prisma/schema.prisma web/prisma/migrations
git commit -m "feat: add Config schema"
```
---
### Task 2: Web crypto util + secret-key plumbing
**Files:**
- Create: `web/src/lib/crypto.ts`
- Test: `web/src/lib/crypto.test.ts`
- Modify: `.env.example`
- Modify: `docker-compose.yml`
**Interfaces:**
- Consumes: `LYRA_SECRET_KEY` env var.
- Produces: `encryptSecret(plaintext: string): string` and `decryptSecret(envelope: string): string` implementing the Global-Constraints crypto contract.
- [ ] **Step 1: Add LYRA_SECRET_KEY to env + compose**
In `.env.example`, add a line (with a documented generator):
```
# 32 random bytes, base64. Generate with: openssl rand -base64 32
LYRA_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
```
In `docker-compose.yml`, add `LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}` to the `environment:` block of BOTH the `web` and `worker` services (next to `DATABASE_URL`).
- [ ] **Step 2: Write the failing test**
`web/src/lib/crypto.test.ts`:
```ts
import { describe, it, expect, beforeAll } from "vitest";
import { encryptSecret, decryptSecret } from "./crypto";
beforeAll(() => {
// base64 of the 32-byte ASCII string "0123456789abcdef0123456789abcdef"
process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
});
describe("crypto", () => {
it("round-trips a secret", () => {
const env = encryptSecret("hunter2");
expect(env).not.toContain("hunter2");
expect(decryptSecret(env)).toBe("hunter2");
});
it("produces a well-formed envelope", () => {
const env = JSON.parse(encryptSecret("x"));
expect(typeof env.iv).toBe("string");
expect(typeof env.ct).toBe("string");
expect(typeof env.tag).toBe("string");
});
it("rejects a tampered envelope", () => {
const env = JSON.parse(encryptSecret("secret"));
env.ct = Buffer.from("garbage").toString("base64");
expect(() => decryptSecret(JSON.stringify(env))).toThrow();
});
});
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `cd web && npx vitest run src/lib/crypto.test.ts`
Expected: FAIL — cannot import `./crypto`.
- [ ] **Step 4: Implement the crypto util**
`web/src/lib/crypto.ts`:
```ts
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
function key(): Buffer {
const b64 = process.env.LYRA_SECRET_KEY;
if (!b64) throw new Error("LYRA_SECRET_KEY is not set");
const k = Buffer.from(b64, "base64");
if (k.length !== 32) throw new Error("LYRA_SECRET_KEY must decode to 32 bytes");
return k;
}
export function encryptSecret(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return JSON.stringify({
iv: iv.toString("base64"),
ct: ct.toString("base64"),
tag: tag.toString("base64"),
});
}
export function decryptSecret(envelope: string): string {
const { iv, ct, tag } = JSON.parse(envelope) as { iv: string; ct: string; tag: string };
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64"));
decipher.setAuthTag(Buffer.from(tag, "base64"));
const pt = Buffer.concat([decipher.update(Buffer.from(ct, "base64")), decipher.final()]);
return pt.toString("utf8");
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `cd web && npx vitest run src/lib/crypto.test.ts`
Expected: PASS — all three tests green.
- [ ] **Step 6: Commit**
```bash
git add web/src/lib/crypto.ts web/src/lib/crypto.test.ts .env.example docker-compose.yml
git commit -m "feat: add web AES-256-GCM secret crypto and LYRA_SECRET_KEY plumbing"
```
---
### Task 3: Config API (get masked / put encrypted)
**Files:**
- Create: `web/src/app/api/config/route.ts`
- Test: `web/src/app/api/config/route.test.ts`
**Interfaces:**
- Consumes: `prisma` (`@/lib/db`), `encryptSecret` (Task 2).
- Produces:
- `PUT /api/config` — body may include any of `{ qobuzEmail, qobuzPassword, slskdUrl, slskdApiKey }` (strings). Each provided field is upserted into `Config`: plain fields (`qobuz.email``qobuzEmail`, `slskd.url``slskdUrl`) stored as-is with `secret=false`; secret fields (`qobuz.password``qobuzPassword`, `slskd.api_key``slskdApiKey`) stored `encryptSecret(value)` with `secret=true`. An empty string for a field means "leave unchanged" (skip it). Responds `200 { ok: true }`.
- `GET /api/config` — responds `200 { qobuzEmail: string, slskdUrl: string, qobuzPasswordSet: boolean, slskdApiKeySet: boolean }` (secret values never returned; `*Set` is whether a row exists for that key).
- [ ] **Step 1: Write the failing test**
`web/src/app/api/config/route.test.ts`:
```ts
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
});
});
```
Add to `web/src/test/setup.ts`'s `beforeEach` cleanup (it currently deletes Job/Request) a line to also clear Config:
```ts
await prisma.config.deleteMany();
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
cd web
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
npx vitest run src/app/api/config/route.test.ts
```
Expected: FAIL — cannot import `./route`.
- [ ] **Step 3: Implement the route**
`web/src/app/api/config/route.ts`:
```ts
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"),
});
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
cd web
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
npx vitest run src/app/api/config/route.test.ts
```
Expected: PASS — all three tests green.
- [ ] **Step 5: Run the full web suite**
Run: `cd web && export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra && npx vitest run`
Expected: PASS — config + requests suites all green.
- [ ] **Step 6: Commit**
```bash
git add web/src/app/api/config web/src/test/setup.ts
git commit -m "feat: add config API with encrypted secret storage"
```
---
### Task 4: Settings page
**Files:**
- Create: `web/src/app/settings/page.tsx`
- Create: `web/src/app/settings/settings-form.tsx`
- Modify: `web/src/app/page.tsx` (add a link to settings)
**Interfaces:**
- Consumes: `GET`/`PUT /api/config` (Task 3).
- Produces: a `/settings` page with a client form pre-filled from `GET /api/config` (plain fields shown; secret fields show a "•••• set" placeholder when `*Set` is true and are left blank otherwise), submitting changed fields via `PUT`.
- [ ] **Step 1: Write the settings form client component**
`web/src/app/settings/settings-form.tsx`:
```tsx
"use client";
import { useEffect, useState } from "react";
type Loaded = { qobuzEmail: string; slskdUrl: string; qobuzPasswordSet: boolean; slskdApiKeySet: boolean };
export function SettingsForm() {
const [qobuzEmail, setQobuzEmail] = useState("");
const [qobuzPassword, setQobuzPassword] = useState("");
const [slskdUrl, setSlskdUrl] = useState("");
const [slskdApiKey, setSlskdApiKey] = useState("");
const [pwSet, setPwSet] = useState(false);
const [keySet, setKeySet] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetch("/api/config")
.then((r) => r.json())
.then((c: Loaded) => {
setQobuzEmail(c.qobuzEmail);
setSlskdUrl(c.slskdUrl);
setPwSet(c.qobuzPasswordSet);
setKeySet(c.slskdApiKeySet);
});
}, []);
async function submit(e: React.FormEvent) {
e.preventDefault();
await fetch("/api/config", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ qobuzEmail, qobuzPassword, slskdUrl, slskdApiKey }),
});
setQobuzPassword("");
setSlskdApiKey("");
setSaved(true);
setPwSet(pwSet || qobuzPassword !== "");
setKeySet(keySet || slskdApiKey !== "");
}
return (
<form onSubmit={submit}>
<label>Qobuz email <input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} /></label>
<label>Qobuz password <input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• (set)" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} /></label>
<label>slskd URL <input aria-label="slskd url" value={slskdUrl} onChange={(e) => setSlskdUrl(e.target.value)} /></label>
<label>slskd API key <input aria-label="slskd api key" type="password" placeholder={keySet ? "•••• (set)" : ""} value={slskdApiKey} onChange={(e) => setSlskdApiKey(e.target.value)} /></label>
<button type="submit">Save</button>
{saved ? <span> Saved.</span> : null}
</form>
);
}
```
- [ ] **Step 2: Write the settings page**
`web/src/app/settings/page.tsx`:
```tsx
import { SettingsForm } from "./settings-form";
export default function SettingsPage() {
return (
<main>
<h1>Settings</h1>
<SettingsForm />
<p><a href="/"> Back to queue</a></p>
</main>
);
}
```
- [ ] **Step 3: Add a settings link to the home page**
In `web/src/app/page.tsx`, add a link above `<Queue />` (inside `<main>`, after `<h1>Lyra</h1>`):
```tsx
<p><a href="/settings">Settings</a></p>
```
- [ ] **Step 4: Verify the app builds**
Run: `cd web && npm run build`
Expected: build completes; `/settings` compiled as a route, no type errors.
- [ ] **Step 5: Commit**
```bash
git add web/src/app/settings web/src/app/page.tsx
git commit -m "feat: add settings page for credentials"
```
---
### Task 5: Worker crypto util (cross-language)
**Files:**
- Create: `worker/lyra_worker/crypto.py`
- Test: `worker/tests/test_crypto.py`
- Modify: `worker/requirements.txt` (add `cryptography`)
**Interfaces:**
- Consumes: `LYRA_SECRET_KEY` env var.
- Produces: `encrypt_secret(plaintext: str) -> str` and `decrypt_secret(envelope: str) -> str` using the SAME envelope format as `web/src/lib/crypto.ts`, so the worker can decrypt what the web app encrypted.
- [ ] **Step 1: Add the dependency**
Append to `worker/requirements.txt`:
```
cryptography>=42,<46
```
Then install into the venv: `worker/.venv/bin/pip install -r worker/requirements.txt`
- [ ] **Step 2: Generate the cross-language fixture**
The Python test must prove it can decrypt an envelope produced by Node using the SAME algorithm/contract as `web/src/lib/crypto.ts`. Generate one with the FIXED test key via a throwaway plain-JS script (no tsx/TS execution needed — plain Node runs `.mjs`):
```bash
cat > /tmp/lyra-gen-fixture.mjs <<'EOF'
import { createCipheriv, randomBytes } from "node:crypto";
const key = Buffer.from("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", "base64");
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update("cross-lang-secret", "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
console.log(JSON.stringify({ iv: iv.toString("base64"), ct: ct.toString("base64"), tag: tag.toString("base64") }));
EOF
node /tmp/lyra-gen-fixture.mjs
```
This uses the identical AES-256-GCM `{iv,ct,tag}` contract as `crypto.ts`. Copy the printed JSON envelope — paste it into the test below as `NODE_FIXTURE` (a one-time captured value; it's deterministic to decrypt). Then `rm /tmp/lyra-gen-fixture.mjs`.
- [ ] **Step 3: Write the failing test**
`worker/tests/test_crypto.py`:
```python
import pytest
from lyra_worker.crypto import decrypt_secret, encrypt_secret
TEST_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" # 32 bytes, base64
# Produced by web/src/lib/crypto.ts encryptSecret("cross-lang-secret") with TEST_KEY (see Step 2).
NODE_FIXTURE = "PASTE_THE_ENVELOPE_FROM_STEP_2_HERE"
@pytest.fixture(autouse=True)
def _key(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", TEST_KEY)
def test_round_trip():
env = encrypt_secret("hunter2")
assert "hunter2" not in env
assert decrypt_secret(env) == "hunter2"
def test_decrypts_node_produced_envelope():
# Cross-language contract: the worker must decrypt what the web app encrypted.
assert decrypt_secret(NODE_FIXTURE) == "cross-lang-secret"
def test_rejects_tampered_envelope():
import json
env = json.loads(encrypt_secret("secret"))
env["ct"] = "Z2FyYmFnZQ==" # "garbage"
with pytest.raises(Exception):
decrypt_secret(json.dumps(env))
```
- [ ] **Step 4: Run the test to verify it fails**
Run:
```bash
cd worker
worker/.venv/bin/python -m pytest tests/test_crypto.py -v
```
Expected: FAIL — `ModuleNotFoundError: No module named 'lyra_worker.crypto'`.
- [ ] **Step 5: Implement the crypto util**
`worker/lyra_worker/crypto.py`:
```python
import base64
import json
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def _key() -> bytes:
b64 = os.environ.get("LYRA_SECRET_KEY")
if not b64:
raise RuntimeError("LYRA_SECRET_KEY is not set")
k = base64.b64decode(b64)
if len(k) != 32:
raise RuntimeError("LYRA_SECRET_KEY must decode to 32 bytes")
return k
def encrypt_secret(plaintext: str) -> str:
iv = os.urandom(12)
full = AESGCM(_key()).encrypt(iv, plaintext.encode("utf-8"), None) # ct || tag(16)
ct, tag = full[:-16], full[-16:]
return json.dumps(
{
"iv": base64.b64encode(iv).decode(),
"ct": base64.b64encode(ct).decode(),
"tag": base64.b64encode(tag).decode(),
}
)
def decrypt_secret(envelope: str) -> str:
e = json.loads(envelope)
iv = base64.b64decode(e["iv"])
ct = base64.b64decode(e["ct"])
tag = base64.b64decode(e["tag"])
pt = AESGCM(_key()).decrypt(iv, ct + tag, None) # AESGCM expects ct || tag
return pt.decode("utf-8")
```
- [ ] **Step 6: Run the test to verify it passes**
Run:
```bash
cd worker
worker/.venv/bin/python -m pytest tests/test_crypto.py -v
```
Expected: PASS — round-trip, the Node-fixture decrypt, and the tamper-rejection all green. (If the Node-fixture test fails, the envelope formats disagree — re-check Step 2's output against the `{iv,ct,tag}` contract.)
- [ ] **Step 7: Commit**
```bash
git add worker/lyra_worker/crypto.py worker/tests/test_crypto.py worker/requirements.txt
git commit -m "feat: add worker AES-256-GCM crypto matching the web envelope"
```
---
### Task 6: Worker config reader
**Files:**
- Create: `worker/lyra_worker/config.py`
- Test: `worker/tests/test_config.py`
**Interfaces:**
- Consumes: the `Config` table, `decrypt_secret` (Task 5), the `conn` fixture.
- Produces: `get_config(conn) -> dict[str, str]` — reads all `Config` rows, returning `{key: value}` where rows with `secret=true` have their `value` decrypted via `decrypt_secret`. Missing keys are simply absent.
- [ ] **Step 1: Write the failing test**
`worker/tests/test_config.py`:
```python
import pytest
from lyra_worker.config import get_config
from lyra_worker.crypto import encrypt_secret
TEST_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
@pytest.fixture(autouse=True)
def _key(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", TEST_KEY)
def _put(conn, key, value, secret):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Config" (key, value, secret, "updatedAt") VALUES (%s, %s, %s, now()) '
'ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, secret = EXCLUDED.secret',
(key, value, secret),
)
conn.commit()
def test_reads_plain_and_decrypts_secret(conn):
_put(conn, "slskd.url", "http://slskd:5030", False)
_put(conn, "slskd.api_key", encrypt_secret("topsecret"), True)
cfg = get_config(conn)
assert cfg["slskd.url"] == "http://slskd:5030"
assert cfg["slskd.api_key"] == "topsecret" # decrypted
def test_missing_keys_absent(conn):
cfg = get_config(conn)
assert "qobuz.password" not in cfg
```
The `conn` fixture cleans Job/Request but not Config. Add Config cleanup: in `worker/tests/conftest.py`, in the `conn` fixture's pre-`yield` cleanup AND post-`yield` teardown, add `cur.execute('DELETE FROM "Config"')` alongside the existing deletes.
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
worker/.venv/bin/python -m pytest tests/test_config.py -v
```
Expected: FAIL — `ModuleNotFoundError: No module named 'lyra_worker.config'`.
- [ ] **Step 3: Implement the config reader**
`worker/lyra_worker/config.py`:
```python
import psycopg
from lyra_worker.crypto import decrypt_secret
def get_config(conn: psycopg.Connection) -> dict[str, str]:
"""Return all config as {key: value}, decrypting rows marked secret."""
with conn.cursor() as cur:
cur.execute('SELECT key, value, secret FROM "Config"')
rows = cur.fetchall()
out: dict[str, str] = {}
for key, value, secret in rows:
out[key] = decrypt_secret(value) if secret else value
return out
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
worker/.venv/bin/python -m pytest tests/test_config.py -v
```
Expected: PASS — both tests green.
- [ ] **Step 5: Run the full worker suite**
Run:
```bash
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
worker/.venv/bin/python -m pytest -v
```
Expected: PASS — every suite green (config, crypto, and the existing pipeline/adapter/etc. suites), output pristine.
- [ ] **Step 6: Commit**
```bash
git add worker/lyra_worker/config.py worker/tests/test_config.py worker/tests/conftest.py
git commit -m "feat: add worker config reader with secret decryption"
```
---
## Notes for the next plans (adapters)
Real adapters (Plans 3b3d) call `get_config(conn)` (or receive the resolved config) to obtain `qobuz.email`/`qobuz.password`, `slskd.url`/`slskd.api_key`, etc., and construct themselves accordingly. `registry.build_adapters()` will read config and include an adapter only when its required keys are present (using each adapter's `health()`), so an unconfigured source is simply skipped rather than failing the pipeline. `LYRA_SECRET_KEY` must be set in the environment of both services (already plumbed here) — document generating a real one with `openssl rand -base64 32` for deployment.