From 349f699bb6299bf85fae12576c975829a7d70a43 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 10 Jul 2026 19:45:08 +0200 Subject: [PATCH] docs: add configuration & secrets plan (slice 1, plan 3a) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-10-lyra-config-secrets.md | 706 ++++++++++++++++++ 1 file changed, 706 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-lyra-config-secrets.md diff --git a/docs/superpowers/plans/2026-07-10-lyra-config-secrets.md b/docs/superpowers/plans/2026-07-10-lyra-config-secrets.md new file mode 100644 index 0000000..c94014a --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-lyra-config-secrets.md @@ -0,0 +1,706 @@ +# 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 = { + 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; + try { + body = (await request.json()) as Record; + } 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 ( +
+ + + + + + {saved ? Saved. : null} +
+ ); +} +``` + +- [ ] **Step 2: Write the settings page** + +`web/src/app/settings/page.tsx`: +```tsx +import { SettingsForm } from "./settings-form"; + +export default function SettingsPage() { + return ( +
+

Settings

+ +

← Back to queue

+
+ ); +} +``` + +- [ ] **Step 3: Add a settings link to the home page** + +In `web/src/app/page.tsx`, add a link above `` (inside `
`, after `

Lyra

`): +```tsx +

Settings

+``` + +- [ ] **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 3b–3d) 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.