move transmission and slskd credentials into the database

both sidecars read their upstream from a new service_connections table instead of
process.env: one row per (user, service), the secret encrypted at rest, upserted
through a /_config route the app drives. transmission gains a Connection section,
soulseek gains one too, and both take over the whole app while nothing is stored.

TRANSMISSION_URL/USER/PASS/RPC_PATH and SLSKD_URL/API_KEY can come out of .env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:04:22 +00:00
co-authored by Claude Opus 5
parent f20d4a300e
commit d7b775113b
26 changed files with 1235 additions and 175 deletions
+38 -8
View File
@@ -1,8 +1,9 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleConfigRoute } from './config';
import { handleOfficerRoute } from './routes';
import { probe } from './rpc';
import { getTransmissionBase } from './upstream';
import { getTransmissionConfig } from './upstream';
// The officer-transmission sidecar. Owns the whole Transmission contract for Officer: the daemon URL and
// credentials, the X-Transmission-Session-Id CSRF handshake, and the translation from Transmission's
@@ -13,6 +14,10 @@ import { getTransmissionBase } from './upstream';
// HTTP CONTRACT — the platform strips its /api/transmission mount prefix before forwarding.
//
// GET /_health ours. Probes the daemon with a cheap session-get.
// GET /_config { configured, connection } — where the daemon is, never the password
// PUT /_config { url, username?, password?, rpcPath? } — validated with a live
// session-get, then stored. A blank password keeps the stored one
// DEL /_config forget the connection
// GET /_officer/session full session settings + version + rpc-version
// POST /_officer/session write a partial settings object (whitelisted), returns it read back
// GET /_officer/stats session-stats: cumulative + current-session counters
@@ -34,6 +39,10 @@ import { getTransmissionBase } from './upstream';
// through one POST body, reports failures as `{"result": "<error>"}` inside a 200, and demands a CSRF token
// that rotates on every daemon restart. Proxying that raw would push all three into the browser. Every
// quirk is absorbed here — see rpc.ts.
//
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
// loopback only, so its presence is the trust signal — a request without it did not come through the
// platform, and which daemon to talk to is per-owner data.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
@@ -57,18 +66,37 @@ const server = Bun.serve({
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/_health') {
if (!getTransmissionBase()) {
return Response.json({ ok: false, error: 'TRANSMISSION_URL not configured' }, { status: 503 });
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
try {
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
} catch (err) {
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
// configured-but-unreachable daemon is the whole reason the flag is on the response.
if (url.pathname === '/_health') {
const cfg = await getTransmissionConfig(userId);
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe();
return Response.json({ ...result, ms: Date.now() - started }, { status: result.ok ? 200 : 502 });
const result = await probe(cfg);
return Response.json(
{ ...result, configured: true, ms: Date.now() - started },
{ status: result.ok ? 200 : 502 },
);
}
if (url.pathname.startsWith('/_officer/')) {
try {
const res = await handleOfficerRoute(req, url);
const res = await handleOfficerRoute(userId, req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
@@ -81,7 +109,9 @@ const server = Bun.serve({
},
});
console.log(`[transmission] listening on 127.0.0.1:${port} -> ${getTransmissionBase() ?? '(TRANSMISSION_URL unset)'}`);
// No upstream in the banner: where the daemon lives is now per-owner state read from the database per
// request, not a constant this process knows at boot.
console.log(`[transmission] listening on 127.0.0.1:${port}`);
type ReplyFn = (msg: SidecarEvent) => void;