Files
officer c11f45c033 soulseek, extracted from the platform as a plugin
Seventh plugin out, and the first that was already dead. Soulseek had been
switched off since 2026-08-13: hono.ts had the router import and its mount
commented out, so createSidecarProxy never ran, registerProxiedPrefix was never
called, the slskd:server announcement fired into a void, and officer-slskd was
not in the ecosystem at all. The permission entry had been reduced to api: [],
which is why assertPermissionTotality was satisfied and the server booted
clean. The UI rendered perfectly and every request 404'd.

So this is a resurrection as well as a move.

The mount is now /api/soulseek, derived via mountPrefix from the appName — all
47 frontend call sites repointed from /slskd/. The credential key in
service_connections stays 'slskd': it names the daemon, not the route. Those
two strings differ on purpose and conflating them reads the wrong row.

The connection stays in core service_connections, as transmission does, so
'slskd' remains in a platform union this plugin cannot extend. Recorded in
PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and
the two halves of the cached browse tree, which slskd has no concept of. Those
three tables had been dropped from the drizzle barrel, so bun db:push would
have deleted them on any server holding data.

313 palette classes converted to the semantic tone set, including a shared
PHASE_TONE the three transfer views previously each copied.
2026-08-19 02:55:02 +00:00

76 lines
3.5 KiB
TypeScript

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<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');
// `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<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([
'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;
}