import { getServiceCredentials } from 'officerdb'; // slskd upstream config + header hygiene for the officer-slskd sidecar. // // All knowledge of the slskd instance — its URL and its API key — lives here. The platform API is a thin // auth+forward proxy and holds NO slskd credentials. // // The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `service_connections` (see // databases/officer_db/src/service-connections/queries.ts). It is deliberately no longer read from the // environment: Bun auto-loads `.env` into every process started in the platform directory, so an // `SLSKD_API_KEY` there was also sitting in `officer`'s own process.env — a credential that drives the whole // Soulseek daemon, held by the one process with no code to use it. Nothing in this file reads process.env. /** Everything needed to reach the daemon. A candidate being validated has this and nothing else yet. */ export type SlskdConfig = { base: string; apiKey: string | null }; /** Trailing slashes off, so `${base}/api/v0/...` never doubles the separator. */ export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); // The transfers view polls every couple of seconds and every request needs the key, so the row is cached // rather than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the // row in psql, which then takes effect within a minute instead of needing a restart. const TTL_MS = 60_000; const cache = new Map(); /** * The owner's slskd connection, or null when nothing is configured — the sidecar then answers 503, and the * UI turns that into the setup form rather than a wall of empty panels. */ export async function getSlskdConfig(userId: number): Promise { const hit = cache.get(userId); if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; const creds = await getServiceCredentials(userId, 'slskd'); // `url` became nullable when services that inherit one instance landed (see the schema column note). // slskd fronts a single daemon and always stores its own, so null here is a malformed row, not an // inheriting one — treat it as unconfigured rather than reaching for somebody else's URL. const cfg = creds?.url ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null; cache.set(userId, { cfg, at: Date.now() }); return cfg; } /** Drop the cached row — called by the config routes after any save or removal. */ export function invalidateSlskdConfig(userId: number): void { cache.delete(userId); } /** The auth header slskd expects, ready to spread. Empty when the daemon has no key configured. */ export const authHeaders = (cfg: SlskdConfig): Record => cfg.apiKey ? { 'X-API-Key': cfg.apiKey } : {}; // Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `host` is dropped so the outgoing // fetch sets the upstream authority itself; everything else — including our injected X-API-Key — passes. const HOP_BY_HOP = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', ]); /** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */ export function stripHopByHop(src: Headers): Headers { const out = new Headers(); src.forEach((value, key) => { const k = key.toLowerCase(); if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; out.set(key, value); }); return out; }