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
+6 -11
View File
@@ -19,22 +19,17 @@ BROWSER_RELAY_PORT=18792
# and never sees them. An unset upstream URL is not fatal — the sidecar logs a warning at boot and
# answers 503 until it is set, so you can run Officer with any subset of these configured.
# Transmission (officer-transmission). TRANSMISSION_USER/PASS are only needed if the daemon has RPC
# auth turned on; leave them empty otherwise, since Transmission rejects an empty Basic header.
# TRANSMISSION_RPC_PATH defaults to /transmission/rpc and only needs setting behind a reverse proxy
# that mounts the RPC endpoint somewhere else.
TRANSMISSION_URL=http://127.0.0.1:9091
TRANSMISSION_USER=
TRANSMISSION_PASS=
# TRANSMISSION_RPC_PATH=/transmission/rpc
# Transmission (officer-transmission) is configured from the app, not from here — Transmission →
# Connection. The daemon URL, the optional RPC auth and the RPC path live in `service_connections`,
# with the password encrypted, so nothing outside the sidecar can read it.
# InvoiceShelf (officer-invoiceshelf) is configured from the app, not from here — Invoices → Connection.
# Instances, their Sanctum tokens and the company each one is pinned to live encrypted in
# `invoiceshelf_accounts`, so nothing outside the sidecar can read a token.
# slskd (officer-slskd). The key is injected as X-API-Key on every forwarded request.
SLSKD_URL=http://127.0.0.1:5030
SLSKD_API_KEY="<slskd api key>"
# slskd (officer-slskd) is configured from the app, not from here — Soulseek → Connection. The
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
# X-API-Key on every forwarded request.
# Vaultwarden (officer-vault). VAULT_STORE_KEY encrypts stored secrets at rest — any strong secret
# of 16+ chars works, and CHANGING IT MAKES EXISTING STORED SECRETS UNREADABLE.
+8
View File
@@ -163,6 +163,14 @@ export {
recordPhotosProbe,
} from './queries/photos';
export type { PhotosAccount, PhotosCredentials } from './queries/photos';
export {
getServiceConnection,
getServiceCredentials,
saveServiceConnection,
deleteServiceConnection,
recordServiceProbe,
} from './queries/service-connections';
export type { ServiceName, ServiceConnection, ServiceCredentials } from './queries/service-connections';
export {
getVaultTokens,
setVaultTokens,
@@ -0,0 +1,153 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { serviceConnections } from '../schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Single-connection services (transmission, slskd) for their sidecars. Callers deal in PLAINTEXT —
// encryption to and from at-rest ciphertext happens here. See ../crypto.ts and ../schema/service-connections.ts.
//
// Two return types, and the split is the safety property:
// ServiceConnection — safe to serialize to the browser. Has NO secret field, only whether one is set.
// ServiceCredentials — the decrypted secret, for the sidecar's own upstream calls. Never returned by a route.
// `connectionCols` is what enforces it: a bare `select()` would put the ciphertext into every response the
// moment someone forgot to strip it.
/** The services that keep a connection here. Extending it is a one-line change, not a migration. */
export type ServiceName = 'transmission' | 'slskd';
export type ServiceConnection = {
id: number;
service: string;
url: string;
username: string | null;
path: string | null;
/** Whether a secret is stored. The secret itself never crosses this boundary, not even masked. */
hasSecret: boolean;
version: string | null;
lastSeenAt: Date | null;
createdAt: Date;
};
export type ServiceCredentials = {
id: number;
url: string;
username: string | null;
secret: string | null;
path: string | null;
};
const connectionCols = {
id: serviceConnections.id,
service: serviceConnections.service,
url: serviceConnections.url,
username: serviceConnections.username,
path: serviceConnections.path,
version: serviceConnections.version,
lastSeenAt: serviceConnections.lastSeenAt,
createdAt: serviceConnections.createdAt,
secret: serviceConnections.secret,
};
type Row = typeof serviceConnections.$inferSelect;
/** Drop the ciphertext, keep the fact of it. The only shape a route is allowed to return. */
const toSafe = (row: Pick<Row, keyof typeof connectionCols>): ServiceConnection => ({
id: row.id,
service: row.service,
url: row.url,
username: row.username,
path: row.path,
hasSecret: !!row.secret,
version: row.version,
lastSeenAt: row.lastSeenAt,
createdAt: row.createdAt,
});
/** What the owner has configured for this service, or null. Never includes the secret. */
export async function getServiceConnection(userId: number, service: ServiceName): Promise<ServiceConnection | null> {
const [row] = await db
.select(connectionCols)
.from(serviceConnections)
.where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service)));
return row ? toSafe(row) : null;
}
/** The same row with its secret decrypted, for the sidecar's upstream calls. */
export async function getServiceCredentials(userId: number, service: ServiceName): Promise<ServiceCredentials | null> {
const [row] = await db
.select()
.from(serviceConnections)
.where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service)));
if (!row) return null;
return {
id: row.id,
url: row.url,
username: row.username,
secret: row.secret ? decryptSecret(row.secret) : null,
path: row.path,
};
}
type SaveServiceConnectionParams = {
userId: number;
service: ServiceName;
url: string;
username?: string | null;
/** Encrypted before write. Undefined leaves a stored secret alone; null clears it. */
secret?: string | null;
path?: string | null;
version?: string | null;
};
/**
* Create or update the one row for this service.
*
* An upsert rather than separate add/edit routes, because there is nothing to add a second of: the UI shows
* one form whose Save means "this is where the daemon is now", whether or not it was ever filled in before.
*/
export async function saveServiceConnection(params: SaveServiceConnectionParams): Promise<ServiceConnection> {
const { userId, service, url, username, secret, path, version } = params;
const now = new Date();
const set: Partial<Row> = { url, updatedAt: now };
if (username !== undefined) set.username = username;
if (secret !== undefined) set.secret = secret === null ? null : encryptSecret(secret);
if (path !== undefined) set.path = path;
if (version !== undefined) {
set.version = version;
set.lastSeenAt = version ? now : null;
}
const [row] = await db
.insert(serviceConnections)
.values({
userId,
service,
url,
username: username ?? null,
secret: secret ? encryptSecret(secret) : null,
path: path ?? null,
version: version ?? null,
lastSeenAt: version ? now : null,
})
.onConflictDoUpdate({ target: [serviceConnections.userId, serviceConnections.service], set })
.returning(connectionCols);
return toSafe(row!);
}
/** Forget the connection entirely — the sidecar then 503s and the UI offers the setup form again. */
export async function deleteServiceConnection(userId: number, service: ServiceName): Promise<boolean> {
const [row] = await db
.delete(serviceConnections)
.where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service)))
.returning({ id: serviceConnections.id });
return !!row;
}
/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */
export async function recordServiceProbe(userId: number, service: ServiceName, version: string | null): Promise<void> {
await db
.update(serviceConnections)
.set({ version, lastSeenAt: new Date() })
.where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service)));
}
@@ -10,6 +10,7 @@ export * from './operations';
export * from './photos';
export * from './pipeline-jobs';
export * from './server';
export * from './service-connections';
export * from './soulseek';
export * from './user-data';
export * from './vault';
@@ -0,0 +1,49 @@
import { pgTable, serial, integer, text, timestamp, unique } from 'drizzle-orm/pg-core';
import { users } from './auth';
// Where a self-hosted service lives, and what it takes to talk to it — for services the owner has exactly
// ONE of. Transmission and slskd today.
//
// This used to be TRANSMISSION_URL / SLSKD_URL / SLSKD_API_KEY in the platform-wide `.env`, which was wrong
// twice over: Bun auto-loads `.env` into EVERY process started in the platform directory, so `officer`
// itself held a slskd credential it has no code to use — and pointing Officer at a daemon was a shell task
// on the server rather than something the owner could do from the app.
//
// NOT a registry, unlike photos_config and invoiceshelf_accounts. Those are plural because the same person
// really does have two Immich users or two companies' books. A second Soulseek daemon or a second
// Transmission is not a thing anyone has, so there is no label, no `is_active`, no switcher — one row per
// (owner, service), enforced below. If that ever stops being true the shape here grows into theirs; until
// then the simpler thing is the honest one.
//
// `secret` is encrypted at rest via ../crypto.ts (a slskd API key drives the whole daemon; Transmission's
// RPC password is the owner's). It is nullable because Transmission is normally run with no RPC auth at
// all, which is not the same as an empty password — see getAuthHeader in the transmission sidecar.
export const serviceConnections = pgTable(
'service_connections',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** 'transmission' | 'slskd'. Text, not an enum: adding a service should not be a schema change. */
service: text('service').notNull(),
// Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator.
url: text('url').notNull(),
/** Transmission RPC basic-auth user. Null/empty means the daemon has no auth, which is the usual case. */
username: text('username'),
secret: text('secret'), // encrypted: slskd API key, or Transmission's RPC password
/**
* Service-specific endpoint path. Only Transmission uses it (`/transmission/rpc` by default) and only a
* reverse proxy makes it differ. Null everywhere else rather than a second table for one column.
*/
path: text('path'),
/** Version seen at the last successful probe — shown in the UI, never used for behaviour. */
version: text('version'),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
// One connection per service per owner. This is what makes the whole "no active flag" simplification safe:
// there is never a second row to choose between, so nothing can be ambiguous about which one is in use.
(t) => [unique('uq_service_connections_user_service').on(t.userId, t.service)],
);
+5 -9
View File
@@ -1,6 +1,6 @@
import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from 'officerdb';
import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb';
import { getSlskdBase, getSlskdApiKey } from './upstream';
import { authHeaders, getSlskdConfig } from './upstream';
// Background share-tree fetches.
//
@@ -128,19 +128,15 @@ export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] {
}
async function run(userId: number, username: string, snapshotId: number): Promise<void> {
const base = getSlskdBase();
if (!base) {
const cfg = await getSlskdConfig(userId);
if (!cfg) {
await failSoulseekBrowse(snapshotId, 'slskd upstream not configured');
return;
}
const started = Date.now();
try {
const headers = new Headers();
const apiKey = getSlskdApiKey();
if (apiKey) headers.set('X-API-Key', apiKey);
const res = await fetch(`${base}/api/v0/users/${encodeURIComponent(username)}/browse`, {
headers,
const res = await fetch(`${cfg.base}/api/v0/users/${encodeURIComponent(username)}/browse`, {
headers: authHeaders(cfg),
signal: AbortSignal.timeout(BROWSE_TIMEOUT_MS),
});
if (!res.ok) {
+114
View File
@@ -0,0 +1,114 @@
import type { SlskdConfig } from './upstream';
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
import { authHeaders, getSlskdConfig, invalidateSlskdConfig, normalizeBase } from './upstream';
// `/_config` — where the slskd daemon is and what key opens it, driven from the app rather than from a
// shell on the server.
//
// The API key is WRITE-ONLY across this boundary. The GET reports the URL and whether a key is stored; it
// has no field that could carry the key itself, masked or otherwise. The only way to change one is to send
// a new one, which is the same shape the photos and headscale registries use.
//
// ONE connection, not a registry: nobody runs two Soulseek daemons. So there is no label, no id in the
// path and no activate route — a save is an upsert over the single row, and the UI is one form.
//
// A save is validated against the live daemon before it is stored: a wrong URL or a rejected key is a 400
// with the reason, not a saved row that makes every later panel fail mysteriously.
export type ProbeResult =
| { ok: true; version: string | null; server: string | null }
| { ok: false; version: string | null; error: string };
/**
* Ask a daemon whether it is really there and whether the key works.
*
* Two calls, because they answer different questions: `/health` is unauthenticated, so a failure there means
* the URL is wrong or slskd is down, while `/api/v0/application` failing after it succeeded means the key is
* the problem. Collapsing them would report "daemon unreachable" for a mistyped key.
*/
export async function probe(cfg: SlskdConfig): Promise<ProbeResult> {
try {
const health = await fetch(`${cfg.base}/health`, { signal: AbortSignal.timeout(5000), redirect: 'manual' });
if (!health.ok) return { ok: false, version: null, error: `daemon returned ${health.status}` };
} catch (err) {
return { ok: false, version: null, error: `could not reach the daemon (${String(err)})` };
}
try {
const res = await fetch(`${cfg.base}/api/v0/application`, {
headers: { Accept: 'application/json', ...authHeaders(cfg) },
signal: AbortSignal.timeout(5000),
redirect: 'manual',
});
if (!res.ok) {
const reason = res.status === 401 || res.status === 403 ? 'API key was rejected' : 'daemon returned';
return { ok: false, version: null, error: `${reason} (${res.status})` };
}
const app = (await res.json()) as {
version?: { current?: string; full?: string };
server?: { state?: string };
};
return { ok: true, version: app.version?.full ?? app.version?.current ?? null, server: app.server?.state ?? null };
} catch (err) {
return { ok: false, version: null, error: String(err) };
}
}
/** What the browser is allowed to know about the connection. Never includes the key. */
async function connectionState(userId: number): Promise<Response> {
const connection = await getServiceConnection(userId, 'slskd');
return Response.json({ configured: !!connection, connection });
}
const bad = (error: string, status = 400) => Response.json({ error }, { status });
type ConnectionBody = { url?: unknown; apiKey?: unknown };
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
/** Save the connection: validated against the live daemon, then stored with the key encrypted. */
async function save(req: Request, userId: number): Promise<Response> {
const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {};
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
// A blank key on an existing connection means "keep the stored one", so changing the URL does not need
// the key re-typed. `undefined` is what saveServiceConnection reads as leave-alone.
const apiKey = typeof body.apiKey === 'string' && body.apiKey.trim() ? body.apiKey.trim() : undefined;
if (!url) return bad('url is required');
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
const current = await getServiceConnection(userId, 'slskd');
if (!apiKey && !current?.hasSecret) return bad('an API key is required');
// Probing needs the actual key, so a URL-only save borrows the stored one through the normal read path
// rather than reaching for the ciphertext here.
const stored = apiKey ? null : await getSlskdConfig(userId);
const result = await probe({ base: url, apiKey: apiKey ?? stored?.apiKey ?? null });
if (!result.ok) return Response.json({ error: result.error }, { status: 400 });
const connection = await saveServiceConnection({
userId,
service: 'slskd',
url,
secret: apiKey,
version: result.version,
});
invalidateSlskdConfig(userId);
return Response.json({ connection, server: result.server });
}
/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
if (subpath && subpath !== '/') return bad('not found', 404);
if (req.method === 'GET') return connectionState(userId);
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
if (req.method === 'DELETE') {
await deleteServiceConnection(userId, 'slskd');
invalidateSlskdConfig(userId);
return connectionState(userId);
}
return bad('method not allowed', 405);
}
+5 -9
View File
@@ -1,5 +1,5 @@
import { getSoulseekBrowseDownload } from 'officerdb';
import { getSlskdBase, getSlskdApiKey } from './upstream';
import { authHeaders, getSlskdConfig } from './upstream';
// Enqueueing downloads out of a cached share tree.
//
@@ -29,8 +29,8 @@ type EnqueueParams = { userId: number; username: string; path: string; file?: st
/** Queue one file, or every file beneath one folder, from the cached tree. Returns how many were sent. */
export async function enqueueFromCache({ userId, username, path, file }: EnqueueParams): Promise<number> {
const base = getSlskdBase();
if (!base) throw new DownloadError('slskd upstream not configured', 503);
const cfg = await getSlskdConfig(userId);
if (!cfg) throw new DownloadError('slskd upstream not configured', 503);
const files = await getSoulseekBrowseDownload({ userId, username, path, file });
if (!files.length) throw new DownloadError('nothing to download at that path', 404);
@@ -41,13 +41,9 @@ export async function enqueueFromCache({ userId, username, path, file }: Enqueue
);
}
const headers = new Headers({ 'content-type': 'application/json' });
const apiKey = getSlskdApiKey();
if (apiKey) headers.set('X-API-Key', apiKey);
const res = await fetch(`${base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
const res = await fetch(`${cfg.base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
method: 'POST',
headers,
headers: { 'content-type': 'application/json', ...authHeaders(cfg) },
body: JSON.stringify(files),
});
if (!res.ok) {
+47 -21
View File
@@ -1,7 +1,8 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { resetStaleSoulseekBrowses } from 'officerdb';
import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream';
import { getSlskdConfig, stripHopByHop } from './upstream';
import { handleConfigRoute, probe } from './config';
import { handleOfficerRoute } from './officer';
// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that
@@ -14,12 +15,21 @@ import { handleOfficerRoute } from './officer';
// HTTP CONTRACT — the platform strips its /api/slskd mount prefix before forwarding, so requests arrive
// here as slskd-root paths (e.g. /api/v0/searches, /api/v0/transfers, /api/v0/session). We inject
// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim.
// `GET /_health` is ours (probes slskd's /health), not part of the slskd contract. The server listens on
// a random loopback port, reported to the API on connect so it can route here.
// The server listens on a random loopback port, reported to the API on connect so it can route here.
//
// GET /_health ours (probes slskd's /health), not part of the slskd contract
// GET /_config { configured, connection } — the URL and whether a key is stored, never the key
// PUT /_config { url, apiKey? } — validated against the daemon, then stored encrypted. A blank
// apiKey on an existing connection keeps the stored one
// DEL /_config forget the connection
//
// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept
// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them
// here rather than in the platform API is what lets the main server stay a pure proxy forever.
//
// 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 the connection is per-owner data.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
//
// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at
@@ -31,9 +41,9 @@ const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '50
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probe.port;
probe.stop(true);
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
@@ -47,18 +57,33 @@ const server = Bun.serve({
maxRequestBodySize: 1024 * 1024 * 1024, // room for uploads / large browse responses
async fetch(req) {
const url = new URL(req.url);
const base = getSlskdBase();
// Reachability probe — ours, not part of the slskd contract.
if (url.pathname === '/_health') {
if (!base) return Response.json({ ok: false, error: 'SLSKD_URL not configured' }, { status: 503 });
const started = Date.now();
try {
const r = await fetch(`${base}/health`, { method: 'GET', signal: AbortSignal.timeout(5000) });
return Response.json({ ok: r.ok, upstreamStatus: r.status, ms: Date.now() - started });
} catch {
return Response.json({ ok: false, error: 'upstream unreachable', ms: Date.now() - started }, { status: 502 });
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(`[slskd] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
const cfg = await getSlskdConfig(userId);
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
// configured-but-broken daemon is the whole reason the flag is on the response.
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(cfg);
const ms = Date.now() - started;
if (!result.ok) return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
return Response.json({ ok: true, configured: true, version: result.version, server: result.server, ms });
}
// Officer-owned routes — answered locally, never proxied (so they work even with slskd down).
@@ -73,16 +98,15 @@ const server = Bun.serve({
}
}
if (!base) return new Response('slskd upstream not configured', { status: 503 });
if (!cfg) return Response.json({ error: 'slskd not connected', configured: false }, { status: 503 });
const target = `${base}${url.pathname}${url.search}`;
const target = `${cfg.base}${url.pathname}${url.search}`;
const method = req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
const started = Date.now();
const headers = stripHopByHop(req.headers);
const apiKey = getSlskdApiKey();
if (apiKey) headers.set('X-API-Key', apiKey);
if (cfg.apiKey) headers.set('X-API-Key', cfg.apiKey);
// Bun/undici require half-duplex to stream a request body straight through.
const init: RequestInit & { duplex?: 'half' } = {
@@ -106,7 +130,9 @@ const server = Bun.serve({
},
});
console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port} -> ${getSlskdBase() ?? '(SLSKD_URL unset)'}`);
// No upstream in the banner: where slskd lives is now per-owner state read from the database per request,
// not a constant this process knows at boot.
console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port}`);
// A background share-tree fetch dies with this process, so anything left 'pending' from the previous life
// would spin in the UI forever. Clear it once, at boot, before serving.
+39 -20
View File
@@ -1,32 +1,51 @@
import { getServiceCredentials } from 'officerdb';
// slskd upstream config + header hygiene for the officer-slskd sidecar.
//
// All knowledge of the slskd instance (its URL and API key) lives in the sidecar, mirroring the
// officer-vault philosophy: the platform API is a thin auth+forward proxy and holds NO slskd
// credentials. The sidecar injects the API key on every forwarded request; the platform never sees it.
// 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/queries/service-connections.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.
const { SLSKD_URL, SLSKD_API_KEY } = 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 };
let warnedUnset = false;
/** Trailing slashes off, so `${base}/api/v0/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
/** The slskd base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */
export function getSlskdBase(): string | null {
const raw = SLSKD_URL?.trim();
if (!raw) {
if (!warnedUnset) {
console.warn('[slskd] SLSKD_URL is unset — the sidecar will respond 503 until it is set');
warnedUnset = true;
}
return null;
}
return raw.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<number, { cfg: SlskdConfig | null; at: number }>();
/**
* 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<SlskdConfig | null> {
const hit = cache.get(userId);
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
const creds = await getServiceCredentials(userId, 'slskd');
const cfg = creds ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null;
cache.set(userId, { cfg, at: Date.now() });
return cfg;
}
/** The slskd API key, injected as `X-API-Key` on every forwarded request. Null when unconfigured. */
export function getSlskdApiKey(): string | null {
const raw = SLSKD_API_KEY?.trim();
return raw ? raw : null;
/** 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<string, string> =>
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([
@@ -0,0 +1,91 @@
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
import { probe } from './rpc';
import {
basicAuth,
getTransmissionConfig,
invalidateTransmissionConfig,
normalizeBase,
normalizeRpcPath,
} from './upstream';
// `/_config` — where the Transmission daemon is, driven from the app rather than from a shell on the server.
//
// ONE connection, not a registry: nobody runs two Transmission daemons. So there is no label, no id in the
// path and no activate route — a save is an upsert over the single row, and the UI is one form.
//
// The URL is the only thing usually needed. Most Transmission daemons run with no RPC auth at all, so
// username/password are optional and an EMPTY username means "no auth" rather than "empty credentials" —
// see basicAuth in upstream.ts, where sending a Basic header anyway makes the daemon reject the request.
// `rpcPath` is there for the reverse-proxy case and defaults to /transmission/rpc.
//
// The password is WRITE-ONLY across this boundary: the GET reports whether one is stored, never its value.
// A save is validated with a live session-get first, so a wrong URL or rejected credentials is a 400 with
// the reason rather than a stored row that makes every later screen fail mysteriously.
/** What the browser is allowed to know about the connection. Never includes the password. */
async function connectionState(userId: number): Promise<Response> {
const connection = await getServiceConnection(userId, 'transmission');
return Response.json({ configured: !!connection, connection });
}
const bad = (error: string, status = 400) => Response.json({ error }, { status });
type ConnectionBody = { url?: unknown; username?: unknown; password?: unknown; rpcPath?: unknown };
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
/** Save the connection: validated against the live daemon, then stored. */
async function save(req: Request, userId: number): Promise<Response> {
const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {};
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
const username = readString(body.username);
const rpcPath = normalizeRpcPath(readString(body.rpcPath));
// A blank password with a username still set means "keep the stored one", so changing the URL does not
// need the password re-typed. Clearing the username clears the auth entirely.
const password = typeof body.password === 'string' && body.password ? body.password : null;
if (!url) return bad('url is required');
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
const current = await getServiceConnection(userId, 'transmission');
const keepPassword = !password && !!username && username === current?.username && current.hasSecret;
// Probing needs the real password, so a save that keeps the stored one borrows it through the normal read
// path rather than reaching for the ciphertext here.
const stored = keepPassword ? await getTransmissionConfig(userId) : null;
const auth = keepPassword ? (stored?.auth ?? null) : basicAuth(username || null, password);
const result = await probe({ base: url, rpcPath, auth });
if (!result.ok) return bad(result.error ?? 'could not reach the daemon');
const connection = await saveServiceConnection({
userId,
service: 'transmission',
url,
username: username || null,
// undefined keeps the stored password; null clears it, which is what dropping the username means.
secret: keepPassword ? undefined : (password ?? null),
path: rpcPath,
version: result.version ?? null,
});
invalidateTransmissionConfig(userId);
return Response.json({ connection, rpcVersion: result.rpcVersion ?? null });
}
/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
if (subpath && subpath !== '/') return bad('not found', 404);
if (req.method === 'GET') return connectionState(userId);
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
if (req.method === 'DELETE') {
await deleteServiceConnection(userId, 'transmission');
invalidateTransmissionConfig(userId);
return connectionState(userId);
}
return bad('method not allowed', 405);
}
+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;
+36 -42
View File
@@ -128,16 +128,10 @@ function pick(body: Record<string, unknown>, allowed: Set<string>): Record<strin
/**
* Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
*
* The platform injects X-Officer-User 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.
* `userId` was authenticated by the platform proxy and validated at the server boundary — every call below
* needs it, because which daemon to talk to is per-owner state read from the database.
*/
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
const officerUser = req.headers.get('X-Officer-User');
if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
const userId = Number(officerUser);
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
export async function handleOfficerRoute(userId: number, req: Request, url: URL): Promise<Response | null> {
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
if (segments.length === 0) return null;
@@ -171,7 +165,7 @@ async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Respo
if (ctx.req.method === 'GET') {
// No `fields` argument: session-get returns everything, and the settings UI reads nearly all of it.
const session = await rpc<SessionSettings>('session-get');
const session = await rpc<SessionSettings>(ctx.userId, 'session-get');
return Response.json({ session });
}
@@ -180,10 +174,10 @@ async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Respo
if (!body) return badRequest('expected a JSON object body');
const args = pick(body, SESSION_SET_FIELDS);
if (Object.keys(args).length === 0) return badRequest('no writable session fields in body');
await rpc('session-set', args);
await rpc(ctx.userId, 'session-set', args);
// Read back rather than echoing the request: Transmission clamps and normalises several of these
// (alt-speed times, queue sizes), so the request body is not what the daemon ends up holding.
const session = await rpc<SessionSettings>('session-get');
const session = await rpc<SessionSettings>(ctx.userId, 'session-get');
return Response.json({ session });
}
@@ -192,7 +186,7 @@ async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Respo
async function handleStats(ctx: OfficerContext): Promise<Response> {
if (ctx.req.method !== 'GET') return methodNotAllowed();
const stats = await rpc<SessionStats>('session-stats');
const stats = await rpc<SessionStats>(ctx.userId, 'session-stats');
return Response.json({ stats });
}
@@ -200,19 +194,19 @@ async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
if (ctx.req.method !== 'GET') return methodNotAllowed();
const path = ctx.url.searchParams.get('path');
if (!path) return badRequest('path query parameter is required');
const result = await rpc<{ path: string; 'size-bytes': number }>('free-space', { path });
const result = await rpc<{ path: string; 'size-bytes': number }>(ctx.userId, 'free-space', { path });
return Response.json({ path: result.path, bytes: result['size-bytes'] });
}
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
if (ctx.req.method !== 'POST') return methodNotAllowed();
const result = await rpc<{ 'port-is-open': boolean }>('port-test');
const result = await rpc<{ 'port-is-open': boolean }>(ctx.userId, 'port-test');
return Response.json({ open: result['port-is-open'] });
}
async function handleBlocklistUpdate(ctx: OfficerContext): Promise<Response> {
if (ctx.req.method !== 'POST') return methodNotAllowed();
const result = await rpc<{ 'blocklist-size': number }>('blocklist-update');
const result = await rpc<{ 'blocklist-size': number }>(ctx.userId, 'blocklist-update');
return Response.json({ size: result['blocklist-size'] });
}
@@ -221,7 +215,7 @@ async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Resp
// GET /_officer/torrents — the list poll.
if (rest.length === 0 && req.method === 'GET') {
const result = await rpc<{ torrents: Torrent[] }>('torrent-get', { fields: [...LIST_FIELDS] });
const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', { fields: [...LIST_FIELDS] });
return Response.json({ torrents: result.torrents.map(decorate) });
}
@@ -231,17 +225,17 @@ async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Resp
if (req.method === 'POST') {
switch (segment) {
case 'add':
return await addTorrent(req);
return await addTorrent(ctx);
case 'action':
return await runAction(req);
return await runAction(ctx);
case 'set':
return await setTorrent(req);
return await setTorrent(ctx);
case 'location':
return await setLocation(req);
return await setLocation(ctx);
case 'rename':
return await renamePath(req);
return await renamePath(ctx);
case 'remove':
return await removeTorrents(req);
return await removeTorrents(ctx);
}
}
@@ -250,7 +244,7 @@ async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Resp
if (!Number.isInteger(id) || id <= 0) return notFound();
if (req.method !== 'GET') return methodNotAllowed();
const result = await rpc<{ torrents: Torrent[] }>('torrent-get', {
const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', {
ids: [id],
fields: [...LIST_FIELDS, ...DETAIL_FIELDS],
});
@@ -262,8 +256,8 @@ async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Resp
return null;
}
async function addTorrent(req: Request): Promise<Response> {
const body = await readJson(req);
async function addTorrent(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const args: Record<string, unknown> = {};
@@ -281,7 +275,7 @@ async function addTorrent(req: Request): Promise<Response> {
const result = await rpc<{
'torrent-added'?: { id: number; name: string; hashString: string };
'torrent-duplicate'?: { id: number; name: string; hashString: string };
}>('torrent-add', args);
}>(ctx.userId, 'torrent-add', args);
const added = result['torrent-added'];
const duplicate = result['torrent-duplicate'];
@@ -292,8 +286,8 @@ async function addTorrent(req: Request): Promise<Response> {
return Response.json({ status: 'added', torrent: null });
}
async function runAction(req: Request): Promise<Response> {
const body = await readJson(req);
async function runAction(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const action = typeof body['action'] === 'string' ? body['action'] : '';
@@ -304,12 +298,12 @@ async function runAction(req: Request): Promise<Response> {
if (!ids) return badRequest('ids must be an array of positive integers');
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
await rpc(method, { ids });
await rpc(ctx.userId, method, { ids });
return Response.json({ ok: true, affected: ids.length });
}
async function setTorrent(req: Request): Promise<Response> {
const body = await readJson(req);
async function setTorrent(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const ids = readIds(body);
@@ -319,12 +313,12 @@ async function setTorrent(req: Request): Promise<Response> {
if (Object.keys(fields).length === 0) return badRequest('no writable torrent fields in body');
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
await rpc('torrent-set', { ids, ...fields });
await rpc(ctx.userId, 'torrent-set', { ids, ...fields });
return Response.json({ ok: true, affected: ids.length });
}
async function setLocation(req: Request): Promise<Response> {
const body = await readJson(req);
async function setLocation(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const ids = readIds(body);
@@ -336,12 +330,12 @@ async function setLocation(req: Request): Promise<Response> {
// `move: false` re-points the torrent at data already sitting there; `true` physically moves it. Getting
// this backwards either loses the data or copies gigabytes unasked, so it is required, not defaulted.
const move = body['move'] === true;
await rpc('torrent-set-location', { ids, location, move });
await rpc(ctx.userId, 'torrent-set-location', { ids, location, move });
return Response.json({ ok: true, affected: ids.length });
}
async function renamePath(req: Request): Promise<Response> {
const body = await readJson(req);
async function renamePath(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const id = Number(body['id']);
@@ -352,12 +346,12 @@ async function renamePath(req: Request): Promise<Response> {
if (!name) return badRequest('name is required');
// torrent-rename-path takes ONE id — an array is accepted but the result is undefined. Enforce it.
await rpc('torrent-rename-path', { ids: [id], path, name });
await rpc(ctx.userId, 'torrent-rename-path', { ids: [id], path, name });
return Response.json({ ok: true });
}
async function removeTorrents(req: Request): Promise<Response> {
const body = await readJson(req);
async function removeTorrents(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const ids = readIds(body);
@@ -365,6 +359,6 @@ async function removeTorrents(req: Request): Promise<Response> {
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
const deleteLocalData = body['deleteLocalData'] === true;
await rpc('torrent-remove', { ids, 'delete-local-data': deleteLocalData });
await rpc(ctx.userId, 'torrent-remove', { ids, 'delete-local-data': deleteLocalData });
return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData });
}
+38 -22
View File
@@ -1,5 +1,6 @@
import type { Torrent, TrackerStat } from './types';
import { getAuthHeader, getRpcPath, getTransmissionBase } from './upstream';
import type { TransmissionConfig } from './upstream';
import { getTransmissionConfig } from './upstream';
// The Transmission RPC call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled exactly once:
@@ -12,7 +13,7 @@ import { getAuthHeader, getRpcPath, getTransmissionBase } from './upstream';
// in a way that looks like an auth bug.
// • Errors are NOT signalled by HTTP status. A perfectly successful-looking 200 carries
// `{"result": "some error string"}`; only `result === 'success'` means it worked.
// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see getAuthHeader.
// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see basicAuth in upstream.ts.
// • `torrent-get` with an unknown field name fails the whole call rather than ignoring the field, so
// LIST_FIELDS/DETAIL_FIELDS are curated against the running daemon's rpc-version, not guessed.
@@ -29,32 +30,45 @@ export class TransmissionError extends Error {
}
}
// The CSRF token for the current daemon session. Module-level because it is a property of the connection,
// not of any one request, and every caller benefits from a refresh any one of them performs.
let sessionId: string | null = null;
// The CSRF token for a daemon session, keyed by endpoint rather than by owner: the token belongs to the
// daemon, so two owners pointed at the same one share a refresh, and re-pointing at a different daemon
// cannot carry a token that daemon never issued.
const sessionIds = new Map<string, string>();
type RpcResponse<T> = { result: string; arguments?: T };
/**
* Issue one RPC call. Retries exactly once on 409 after adopting the new session id; a second 409 means
* something other than a stale token (a proxy stripping the header, most likely) and is surfaced.
*/
export async function rpc<T = unknown>(method: string, args: Record<string, unknown> = {}): Promise<T> {
return call<T>(method, args, true);
/** Issue one RPC call against the owner's configured daemon. */
export async function rpc<T = unknown>(userId: number, method: string, args: Record<string, unknown> = {}): Promise<T> {
const cfg = await getTransmissionConfig(userId);
if (!cfg) throw new TransmissionError(503, 'transmission is not connected');
return call<T>(cfg, method, args, true);
}
async function call<T>(method: string, args: Record<string, unknown>, mayRetry: boolean): Promise<T> {
const base = getTransmissionBase();
if (!base) throw new TransmissionError(503, 'TRANSMISSION_URL is not configured');
/** The same call against a connection that may not be stored yet — used to validate one before saving it. */
export async function rpcWith<T = unknown>(
cfg: TransmissionConfig,
method: string,
args: Record<string, unknown> = {},
): Promise<T> {
return call<T>(cfg, method, args, true);
}
async function call<T>(
cfg: TransmissionConfig,
method: string,
args: Record<string, unknown>,
mayRetry: boolean,
): Promise<T> {
const endpoint = `${cfg.base}${cfg.rpcPath}`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const sessionId = sessionIds.get(endpoint);
if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
const auth = getAuthHeader();
if (auth) headers['Authorization'] = auth;
if (cfg.auth) headers['Authorization'] = cfg.auth;
let res: Response;
try {
res = await fetch(`${base}${getRpcPath()}`, {
res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ method, arguments: args }),
@@ -68,8 +82,8 @@ async function call<T>(method: string, args: Record<string, unknown>, mayRetry:
if (res.status === 409) {
const fresh = res.headers.get('X-Transmission-Session-Id');
if (fresh && mayRetry) {
sessionId = fresh;
return call<T>(method, args, false);
sessionIds.set(endpoint, fresh);
return call<T>(cfg, method, args, false);
}
throw new TransmissionError(502, 'transmission rejected the session id handshake');
}
@@ -85,10 +99,12 @@ async function call<T>(method: string, args: Record<string, unknown>, mayRetry:
return (body.arguments ?? {}) as T;
}
/** Reachability + credential probe, used by /_health. */
export async function probe(): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
/** Reachability + credential probe, used by /_health and before a connection is saved. */
export async function probe(
cfg: TransmissionConfig,
): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
try {
const args = await rpc<{ version: string; 'rpc-version': number }>('session-get', {
const args = await rpcWith<{ version: string; 'rpc-version': number }>(cfg, 'session-get', {
fields: ['version', 'rpc-version'],
});
return { ok: true, version: args.version, rpcVersion: args['rpc-version'] };
+55 -29
View File
@@ -1,46 +1,72 @@
import { getServiceCredentials } from 'officerdb';
// Transmission upstream config for the officer-transmission sidecar.
//
// All knowledge of the Transmission daemon (its URL, its RPC path and its credentials) lives here,
// mirroring the officer-slskd/officer-vault philosophy: the platform API is a thin auth+forward proxy and
// holds NO Transmission credentials.
// 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/queries/service-connections.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.
const { TRANSMISSION_URL, TRANSMISSION_USER, TRANSMISSION_PASS, TRANSMISSION_RPC_PATH } = 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 };
let warnedUnset = false;
/** The Transmission base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */
export function getTransmissionBase(): string | null {
const raw = TRANSMISSION_URL?.trim();
if (!raw) {
if (!warnedUnset) {
console.warn('[transmission] TRANSMISSION_URL is unset — the sidecar will respond 503 until it is set');
warnedUnset = true;
}
return null;
}
return raw.replace(/\/+$/, '');
}
/** 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 configurable rather than hardcoded.
* mount it anywhere, so it is stored per connection rather than hardcoded.
*/
export function getRpcPath(): string {
const raw = TRANSMISSION_RPC_PATH?.trim();
if (!raw) return '/transmission/rpc';
return raw.startsWith('/') ? raw : `/${raw}`;
}
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.
* username is actually set, which is why the stored username is nullable rather than defaulting to ''.
*/
export function getAuthHeader(): string | null {
const user = TRANSMISSION_USER?.trim();
export function basicAuth(username: string | null, password: string | null): string | null {
const user = username?.trim();
if (!user) return null;
const pass = TRANSMISSION_PASS ?? '';
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
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<number, { cfg: TransmissionConfig | null; at: number }>();
/**
* 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<TransmissionConfig | null> {
const hit = cache.get(userId);
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
const creds = await getServiceCredentials(userId, 'transmission');
const cfg = creds
? {
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);
}
@@ -0,0 +1,175 @@
import type { ServiceConnection } from '../../hooks/useServiceConnection';
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
serviceErrorMessage,
useServiceConnection,
useServiceConnectionActions,
useServiceHealth,
} from '../../hooks/useServiceConnection';
// Connecting a slskd daemon to Officer, from the app.
//
// Both the setup wizard and the permanent settings page: SoulseekView renders it in place of whatever
// section the nav asked for while nothing is connected, and the Connection section renders it for good.
// One component, so re-pointing at a different daemon later goes through exactly the code path that
// stored the first one.
//
// The API key is write-only — the GET it reads has no field that could carry one back, so the input is
// always blank and an empty input means "keep the stored key".
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** slskd's own default HTTP port. Officer dials it from the server, not from this browser. */
const DEFAULT_URL = 'http://localhost:5030';
const URL_HINT =
"The daemon's base URL. Officer reaches it from the server, not from this browser — so localhost here " +
'means the machine Officer runs on.';
const KEY_HINT =
'An API key from slskd.yml (web.authentication.api_keys). Officer sends it as X-API-Key on every call.';
type SaveInput = Record<string, unknown> & { url: string };
export const SoulseekConnection = () => {
const { data, isLoading } = useServiceConnection('slskd');
const { data: health } = useServiceHealth('slskd');
const { save, forget } = useServiceConnectionActions<SaveInput>('slskd');
const connection = data?.connection ?? null;
const [url, setUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState('');
// Seed from the stored row once it arrives. Keyed on its id so a save (same id) doesn't stomp what the
// owner is still typing, while forgetting and re-adding does reset the form.
useEffect(() => {
setUrl(connection?.url ?? '');
setApiKey('');
}, [connection?.id]);
const submit = async () => {
setError('');
try {
await save.mutateAsync({ url: url.trim(), apiKey: apiKey.trim() });
setApiKey('');
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const remove = async () => {
setError('');
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<header className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-sky-500/15 text-sky-500">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">slskd daemon</h2>
<p className={HINT}>
{connection
? 'Where Officer talks to slskd. Saving re-checks the daemon before storing anything.'
: 'Point Officer at your slskd daemon. It needs the URL and one API key.'}
</p>
</div>
</header>
{connection && <StatusRow connection={connection} health={health} />}
<div className="flex flex-col gap-4 rounded-xl border p-4">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">Server URL</span>
<Input
value={url}
onChange={(ev) => setUrl(ev.target.value)}
placeholder={DEFAULT_URL}
autoFocus={!connection}
autoComplete="off"
spellCheck={false}
/>
<span className={HINT}>{URL_HINT}</span>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">API key</span>
<Input
value={apiKey}
onChange={(ev) => setApiKey(ev.target.value)}
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : 'slskd API key'}
type="password"
autoComplete="off"
spellCheck={false}
/>
<span className={HINT}>{KEY_HINT}</span>
</label>
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={submit}
disabled={!url.trim() || (!apiKey.trim() && !connection?.hasSecret) || save.isPending}
>
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{connection ? 'Save' : 'Connect'}
</Button>
{connection && (
<Button size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Disconnect
</Button>
)}
</div>
</div>
</div>
</div>
);
};
type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined };
const StatusRow = ({ connection, health }: StatusRowProps) => (
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
{health?.ok ? (
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-emerald-500" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
)}
<div className="min-w-0">
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
<div className="truncate text-muted-foreground">
{connection.url}
{connection.version ? ` · slskd ${connection.version}` : ''}
</div>
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
</div>
</div>
);
@@ -1,6 +1,16 @@
import type { LucideIcon } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { LayoutGrid, Search, ArrowDownToLine, ArrowUpFromLine, Hash, MessageCircle, Users, Server } from 'lucide-react';
import {
LayoutGrid,
Search,
ArrowDownToLine,
ArrowUpFromLine,
Hash,
MessageCircle,
Users,
Server,
Plug,
} from 'lucide-react';
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes
@@ -15,6 +25,7 @@ const ICONS: Record<SoulseekSectionId, LucideIcon> = {
chat: MessageCircle,
users: Users,
system: Server,
connection: Plug,
};
export const SoulseekNav = () => {
@@ -50,7 +61,9 @@ export const SoulseekNav = () => {
{active && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`} />
<Icon
className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</button>
);
@@ -10,6 +10,8 @@ import { SoulseekDashboard } from './SoulseekDashboard';
import { SoulseekChat } from './SoulseekChat';
import { SoulseekUsers } from './SoulseekUsers';
import { SoulseekSystem } from './SoulseekSystem';
import { SoulseekConnection } from './SoulseekConnection';
import { useServiceConnection } from '../../hooks/useServiceConnection';
// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. The panel
// header's +/- controls set a per-panel zoom factor, persisted via useDashboardState (same store as the
@@ -49,6 +51,8 @@ const sectionView = (section: SoulseekSectionId) => {
return <SoulseekUsers />;
case 'system':
return <SoulseekSystem />;
case 'connection':
return <SoulseekConnection />;
default:
return <Placeholder id={section} />;
}
@@ -59,8 +63,13 @@ type SoulseekViewProps = { panelId: string };
export const SoulseekView = ({ panelId }: SoulseekViewProps) => {
const [section] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
const { value: zoom } = useDashboardState<number>(soulseekZoomKey(panelId), 1);
const { data: connection, isLoading } = useServiceConnection('slskd');
const z = zoom ?? 1;
// Nothing connected yet: the setup form takes over every section, because none of them can do anything
// without a daemon. Zoom is skipped for it too — it is a form, not a dense slskd panel.
if (!isLoading && !connection?.configured) return <SoulseekConnection />;
if (z === 1) return <div className="h-full w-full">{sectionView(section)}</div>;
// transform: scale doesn't reflow, so size the box to 1/z and let the scale bring it back to 100%.
@@ -255,7 +255,8 @@ export type SoulseekSectionId =
| 'rooms'
| 'chat'
| 'users'
| 'system';
| 'system'
| 'connection';
export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'search', label: 'Search' },
@@ -265,6 +266,7 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
{ id: 'chat', label: 'Chat' },
{ id: 'users', label: 'Users' },
{ id: 'system', label: 'System' },
{ id: 'connection', label: 'Connection' },
];
// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users
@@ -0,0 +1,219 @@
import type { ServiceConnection } from '../../hooks/useServiceConnection';
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
serviceErrorMessage,
useServiceConnection,
useServiceConnectionActions,
useServiceHealth,
} from '../../hooks/useServiceConnection';
// Connecting a Transmission daemon to Officer, from the app.
//
// This screen is BOTH the setup wizard and the permanent settings page: TransmissionView renders it in place
// of whatever section the URL asks for while nothing is connected, and /transmission/connection renders it
// for good. One component, so re-pointing at a different daemon later goes through exactly the code path
// that stored the first one.
//
// ONE connection, not a registry — nobody runs two Transmission daemons. And usually one FIELD: most
// daemons run with no RPC auth at all, which is why username/password sit behind a disclosure rather than
// in the owner's way. An empty username means "no auth", not "empty credentials"; the sidecar is careful
// about that distinction because Transmission rejects a request carrying an empty Basic header.
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** Transmission's own default RPC port. Officer dials it from the server, not from this browser. */
const DEFAULT_URL = 'http://localhost:9091';
const URL_HINT =
"The daemon's base URL, without the RPC path. Officer reaches it from the server, not from this browser " +
'— so localhost here means the machine Officer runs on.';
const AUTH_HINT =
'Only if the daemon has rpc-authentication-required set. Leave the username empty for the usual case: an ' +
'empty username means no authentication, and sending a blank one anyway makes Transmission refuse the call.';
const PATH_HINT = 'Only differs behind a reverse proxy that mounts the RPC endpoint somewhere else.';
type FieldProps = {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
autoFocus?: boolean;
};
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">{label}</span>
<Input
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
type={type}
autoFocus={autoFocus}
autoComplete="off"
spellCheck={false}
/>
{hint && <span className={HINT}>{hint}</span>}
</label>
);
type SaveInput = Record<string, unknown> & { url: string };
export const ConnectionView = () => {
const { data, isLoading } = useServiceConnection('transmission');
const { data: health } = useServiceHealth('transmission');
const { save, forget } = useServiceConnectionActions<SaveInput>('transmission');
const connection = data?.connection ?? null;
const [url, setUrl] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rpcPath, setRpcPath] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState('');
// Seed the form from the stored row once it arrives. Keyed on the row's id so a save (which returns the
// same id) does not stomp what the owner is still typing, but forgetting and re-adding does reset it.
useEffect(() => {
setUrl(connection?.url ?? '');
setUsername(connection?.username ?? '');
setRpcPath(connection?.path ?? '');
setPassword('');
setShowAdvanced(!!connection?.username);
}, [connection?.id]);
const submit = async () => {
setError('');
try {
await save.mutateAsync({ url: url.trim(), username: username.trim(), password, rpcPath: rpcPath.trim() });
setPassword('');
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const remove = async () => {
setError('');
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<header className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500/15 text-amber-500">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">Transmission daemon</h2>
<p className={HINT}>
{connection
? 'Where Officer talks to Transmission. Saving re-checks the daemon before storing anything.'
: 'Point Officer at your Transmission daemon. Usually the URL is all it needs.'}
</p>
</div>
</header>
{connection && <StatusRow connection={connection} health={health} />}
<div className="flex flex-col gap-4 rounded-xl border p-4">
<Field
label="Server URL"
hint={URL_HINT}
value={url}
onChange={setUrl}
placeholder={DEFAULT_URL}
autoFocus={!connection}
/>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="self-start text-[11px] font-medium text-primary hover:underline"
>
{showAdvanced ? 'Hide' : 'Show'} authentication and RPC path
</button>
{showAdvanced && (
<>
<Field label="Username" hint={AUTH_HINT} value={username} onChange={setUsername} placeholder="(none)" />
<Field
label="Password"
value={password}
onChange={setPassword}
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : '(none)'}
type="password"
/>
<Field
label="RPC path"
hint={PATH_HINT}
value={rpcPath}
onChange={setRpcPath}
placeholder="/transmission/rpc"
/>
</>
)}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button size="sm" onClick={submit} disabled={!url.trim() || save.isPending}>
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{connection ? 'Save' : 'Connect'}
</Button>
{connection && (
<Button size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Disconnect
</Button>
)}
</div>
</div>
</div>
</div>
);
};
type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined };
const StatusRow = ({ connection, health }: StatusRowProps) => (
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
{health?.ok ? (
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-emerald-500" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
)}
<div className="min-w-0">
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
<div className="truncate text-muted-foreground">
{connection.url}
{connection.version ? ` · Transmission ${connection.version}` : ''}
</div>
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
</div>
</div>
);
@@ -133,8 +133,8 @@ export const TorrentsView = () => {
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
<div className="text-sm font-medium">Cannot reach Transmission</div>
<p className="max-w-md text-xs text-muted-foreground">
The officer-transmission sidecar answered with an error. Check that the daemon is running and that
TRANSMISSION_URL points at it.
The officer-transmission sidecar answered with an error. Check that the daemon is running, and that the URL
under Connection still points at it.
</p>
</div>
);
@@ -10,6 +10,7 @@ import {
Folder,
Gauge,
ListChecks,
Plug,
Radio,
Settings,
Tag,
@@ -33,6 +34,7 @@ const ICONS: Record<TransmissionSectionId, LucideIcon> = {
torrents: ListChecks,
stats: Gauge,
settings: Settings,
connection: Plug,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
@@ -1,14 +1,24 @@
import { useServiceConnection } from '../../hooks/useServiceConnection';
import { useTransmissionSection } from './useTransmissionSection';
import { TorrentsView } from './TorrentsView';
import { StatsView } from './StatsView';
import { SettingsView } from './SettingsView';
import { ConnectionView } from './ConnectionView';
// Right panel of the /transmission workspace — renders the section named by the URL.
//
// With no daemon configured every other section can only render an error, so the connection form takes over
// until there is one. The URL is left alone: once connected, the section already in it is what appears.
export const TransmissionView = () => {
const section = useTransmissionSection();
const { data, isLoading } = useServiceConnection('transmission');
if (!isLoading && !data?.configured) return <ConnectionView />;
switch (section) {
case 'connection':
return <ConnectionView />;
case 'stats':
return <StatsView />;
case 'settings':
@@ -10,6 +10,7 @@ export const TRANSMISSION_SECTIONS = [
{ id: 'torrents', label: 'Torrents' },
{ id: 'stats', label: 'Statistics' },
{ id: 'settings', label: 'Settings' },
{ id: 'connection', label: 'Connection' },
] as const;
export type TransmissionSectionId = (typeof TRANSMISSION_SECTIONS)[number]['id'];
@@ -2,3 +2,4 @@ export * from './useFilesAPI';
export * from './useFileViewerPanels';
export * from './useChat';
export * from './useDock';
export * from './useServiceConnection';
@@ -0,0 +1,114 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
// The data layer for a SINGLE-connection sidecar — one where the owner has exactly one of the thing, so the
// whole configuration is one row and one form. Transmission and slskd both work this way; the registries
// (photos, invoiceshelf) do not, and deliberately have their own hooks.
//
// Every such sidecar serves the same three-verb contract at `/<service>/_config`, so the hook is written
// once here rather than copied per app:
//
// GET → { configured, connection } never any secret, only whether one is stored
// PUT → { connection } upsert, validated against the live service first
// DELETE → { configured: false } forget it
//
// plus `/<service>/_health`, whose failure BODIES are the useful part — see useServiceHealth.
export type ServiceConnection = {
id: number;
service: string;
url: string;
username: string | null;
path: string | null;
hasSecret: boolean;
version: string | null;
lastSeenAt: string | null;
createdAt: string;
};
export type ServiceConnectionState = { configured: boolean; connection: ServiceConnection | null };
export type ServiceHealth = { ok: boolean; configured: boolean; version?: string | null; error?: string; ms?: number };
/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
export function serviceErrorMessage(err: unknown): string {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
} catch {
/* plain text */
}
return raw.slice(0, 300);
}
const configKey = (service: string) => [service, 'connection'] as const;
const healthKey = (service: string) => [service, 'health'] as const;
export function useServiceConnection(service: string) {
const { get } = useClient();
return useQuery({
queryKey: configKey(service),
queryFn: () => get<ServiceConnectionState>(`/${service}/_config`),
staleTime: 60_000,
retry: false,
});
}
/**
* Health, including its failure bodies.
*
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
* the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
* so the throw is turned back into the answer rather than an error state.
*/
export function useServiceHealth(service: string) {
const { get } = useClient();
return useQuery({
queryKey: healthKey(service),
queryFn: async (): Promise<ServiceHealth> => {
try {
return await get<ServiceHealth>(`/${service}/_health`);
} catch (err) {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw === 'string') {
try {
const body = JSON.parse(raw) as ServiceHealth;
if (body && body.ok === false) return body;
} catch {
/* not the sidecar's body */
}
}
// Anything else — the platform proxy, auth, the sidecar being down — is a configured service that is
// failing, not an unconfigured one. Never offer the setup form on a guess.
return { ok: false, configured: true, error: serviceErrorMessage(err) };
}
},
staleTime: 60_000,
retry: false,
});
}
/**
* Save and forget. Both invalidate the WHOLE service prefix, not just the connection: re-pointing at a
* different daemon invalidates every list, stat and setting already in the cache.
*/
export function useServiceConnectionActions<TInput extends Record<string, unknown>>(service: string) {
const { put, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: [service] });
const save = useMutation({
mutationFn: (input: TInput) => put<{ connection: ServiceConnection }>(`/${service}/_config`, input),
onSuccess: invalidate,
});
const forget = useMutation({
mutationFn: () => del<ServiceConnectionState>(`/${service}/_config`),
onSuccess: invalidate,
});
return { save, forget };
}