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

25 KiB
Raw Permalink Blame History

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:

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_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:

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 into Config: plain fields (qobuz.emailqobuzEmail, slskd.urlslskdUrl) stored as-is with secret=false; secret fields (qobuz.passwordqobuzPassword, slskd.api_keyslskdApiKey) 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:

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 /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:

"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 (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):

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 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:

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 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.