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.
76 lines
3.5 KiB
TypeScript
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;
|
|
}
|