Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
25 KiB
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}wherectis the ciphertext WITHOUT the tag andtagis the 16-byte GCM tag. (Node keeps the tag separate viagetAuthTag(); Python'sAESGCMappends 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/configreturns 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_KEYis provided to BOTH thewebandworkerservices 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
Configtable —key String @id,value String,secret Boolean @default(false),updatedAt DateTime @updatedAt. -
Step 1: Add the model
Append to web/prisma/schema.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):
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:
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
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_KEYenv var. -
Produces:
encryptSecret(plaintext: string): stringanddecryptSecret(envelope: string): stringimplementing 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:
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:
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
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 intoConfig: plain fields (qobuz.email→qobuzEmail,slskd.url→slskdUrl) stored as-is withsecret=false; secret fields (qobuz.password→qobuzPassword,slskd.api_key→slskdApiKey) storedencryptSecret(value)withsecret=true. An empty string for a field means "leave unchanged" (skip it). Responds200 { ok: true }.GET /api/config— responds200 { qobuzEmail: string, slskdUrl: string, qobuzPasswordSet: boolean, slskdApiKeySet: boolean }(secret values never returned;*Setis whether a row exists for that key).
-
Step 1: Write the failing test
web/src/app/api/config/route.test.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:
await prisma.config.deleteMany();
- Step 2: Run the test to verify it fails
Run:
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:
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:
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
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
/settingspage with a client form pre-filled fromGET /api/config(plain fields shown; secret fields show a "•••• set" placeholder when*Setis true and are left blank otherwise), submitting changed fields viaPUT. -
Step 1: Write the settings form client component
web/src/app/settings/settings-form.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:
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>):
<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
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(addcryptography)
Interfaces:
-
Consumes:
LYRA_SECRET_KEYenv var. -
Produces:
encrypt_secret(plaintext: str) -> stranddecrypt_secret(envelope: str) -> strusing the SAME envelope format asweb/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):
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:
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:
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:
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:
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
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
Configtable,decrypt_secret(Task 5), theconnfixture. -
Produces:
get_config(conn) -> dict[str, str]— reads allConfigrows, returning{key: value}where rows withsecret=truehave theirvaluedecrypted viadecrypt_secret. Missing keys are simply absent. -
Step 1: Write the failing test
worker/tests/test_config.py:
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:
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:
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:
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:
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
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.