import { getServiceCredentials } from 'officerdb'; // Transmission upstream config for the officer-transmission sidecar. // // All knowledge of the daemon — its URL, its RPC path and its credentials — lives here. The platform API is // a thin auth+forward proxy and holds NO Transmission credentials. // // The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored in `service_connections` (see // databases/officer_db/src/service-connections/queries.ts), no longer read from the environment: Bun // auto-loads `.env` into every process started in the platform directory, so TRANSMISSION_* was also // sitting in `officer`'s own process.env, and pointing Officer at a daemon meant editing a file on the // server. Nothing in this file reads process.env. /** Everything needed to make one RPC call. A candidate being validated has this and nothing else yet. */ export type TransmissionConfig = { base: string; rpcPath: string; auth: string | null }; /** Trailing slashes off, so `${base}${rpcPath}` never doubles the separator. */ export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); /** * The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can * mount it anywhere, so it is stored per connection rather than hardcoded. */ export const normalizeRpcPath = (raw: string | null | undefined): string => { const path = raw?.trim(); if (!path) return '/transmission/rpc'; return path.startsWith('/') ? path : `/${path}`; }; /** * The `Authorization: Basic …` header value, or null when the daemon has no auth configured. * * Transmission treats an empty username as "no authentication required" — sending an empty Basic header in * that case is not merely useless, it makes the daemon reject the request. So this returns null unless a * username is actually set, which is why the stored username is nullable rather than defaulting to ''. */ export function basicAuth(username: string | null, password: string | null): string | null { const user = username?.trim(); if (!user) return null; return `Basic ${Buffer.from(`${user}:${password ?? ''}`).toString('base64')}`; } // The torrent list polls every couple of seconds, 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 Transmission connection, or null when nothing is configured — the sidecar then answers 503, * and the UI turns that into the setup form rather than an empty torrent list. */ export async function getTransmissionConfig(userId: number): Promise { const hit = cache.get(userId); if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; const creds = await getServiceCredentials(userId, 'transmission'); // `url` became nullable when services that inherit one instance landed (see the schema column note). // Transmission fronts a single daemon and always stores its own, so null here is a malformed row. const cfg = creds?.url ? { base: normalizeBase(creds.url), rpcPath: normalizeRpcPath(creds.path), auth: basicAuth(creds.username, 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 invalidateTransmissionConfig(userId: number): void { cache.delete(userId); }