From d7b775113b24ff03399ba8d77df2c6e7e0491646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 3 Aug 2026 18:04:22 +0000 Subject: [PATCH] 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 --- .env.example | 17 +- src/databases/officer_db/src/index.ts | 8 + .../src/queries/service-connections.ts | 153 ++++++++++++ src/databases/officer_db/src/schema/index.ts | 1 + .../src/schema/service-connections.ts | 49 ++++ src/servers/sidecar/slskd/browse.ts | 14 +- src/servers/sidecar/slskd/config.ts | 114 +++++++++ src/servers/sidecar/slskd/download.ts | 14 +- src/servers/sidecar/slskd/index.ts | 66 ++++-- src/servers/sidecar/slskd/upstream.ts | 59 +++-- src/servers/sidecar/transmission/config.ts | 91 ++++++++ src/servers/sidecar/transmission/index.ts | 46 +++- src/servers/sidecar/transmission/routes.ts | 78 +++---- src/servers/sidecar/transmission/rpc.ts | 60 +++-- src/servers/sidecar/transmission/upstream.ts | 84 ++++--- .../src/apps/Soulseek/SoulseekConnection.tsx | 175 ++++++++++++++ .../src/apps/Soulseek/SoulseekNav.tsx | 17 +- .../src/apps/Soulseek/SoulseekView.tsx | 9 + .../officerdev/src/apps/Soulseek/shared.ts | 4 +- .../src/apps/Transmission/ConnectionView.tsx | 219 ++++++++++++++++++ .../src/apps/Transmission/TorrentsView.tsx | 4 +- .../src/apps/Transmission/TransmissionNav.tsx | 2 + .../apps/Transmission/TransmissionView.tsx | 10 + .../src/apps/Transmission/shared.ts | 1 + src/workspaces/officerdev/src/hooks/index.ts | 1 + .../src/hooks/useServiceConnection.ts | 114 +++++++++ 26 files changed, 1235 insertions(+), 175 deletions(-) create mode 100644 src/databases/officer_db/src/queries/service-connections.ts create mode 100644 src/databases/officer_db/src/schema/service-connections.ts create mode 100644 src/servers/sidecar/slskd/config.ts create mode 100644 src/servers/sidecar/transmission/config.ts create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekConnection.tsx create mode 100644 src/workspaces/officerdev/src/apps/Transmission/ConnectionView.tsx create mode 100644 src/workspaces/officerdev/src/hooks/useServiceConnection.ts diff --git a/.env.example b/.env.example index 82c21b0d..fcf54701 100644 --- a/.env.example +++ b/.env.example @@ -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 (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. diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 52b421ce..a4a476e3 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -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, diff --git a/src/databases/officer_db/src/queries/service-connections.ts b/src/databases/officer_db/src/queries/service-connections.ts new file mode 100644 index 00000000..ea69b71d --- /dev/null +++ b/src/databases/officer_db/src/queries/service-connections.ts @@ -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): 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 { + 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 { + 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 { + const { userId, service, url, username, secret, path, version } = params; + const now = new Date(); + + const set: Partial = { 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 { + 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 { + await db + .update(serviceConnections) + .set({ version, lastSeenAt: new Date() }) + .where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service))); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 21638d2c..d6606be2 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/schema/service-connections.ts b/src/databases/officer_db/src/schema/service-connections.ts new file mode 100644 index 00000000..edd0c11e --- /dev/null +++ b/src/databases/officer_db/src/schema/service-connections.ts @@ -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)], +); diff --git a/src/servers/sidecar/slskd/browse.ts b/src/servers/sidecar/slskd/browse.ts index 0b249ef3..b14d63e7 100644 --- a/src/servers/sidecar/slskd/browse.ts +++ b/src/servers/sidecar/slskd/browse.ts @@ -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 { - 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) { diff --git a/src/servers/sidecar/slskd/config.ts b/src/servers/sidecar/slskd/config.ts new file mode 100644 index 00000000..87755cbe --- /dev/null +++ b/src/servers/sidecar/slskd/config.ts @@ -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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/servers/sidecar/slskd/download.ts b/src/servers/sidecar/slskd/download.ts index 8eb5c80f..5dc67c62 100644 --- a/src/servers/sidecar/slskd/download.ts +++ b/src/servers/sidecar/slskd/download.ts @@ -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 { - 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) { diff --git a/src/servers/sidecar/slskd/index.ts b/src/servers/sidecar/slskd/index.ts index f16bd24a..6b3c1e1c 100644 --- a/src/servers/sidecar/slskd/index.ts +++ b/src/servers/sidecar/slskd/index.ts @@ -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,20 +57,35 @@ 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(); + 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 { - 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 }); + 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). if (url.pathname.startsWith('/_officer/')) { try { @@ -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. diff --git a/src/servers/sidecar/slskd/upstream.ts b/src/servers/sidecar/slskd/upstream.ts index d47e245a..2b445663 100644 --- a/src/servers/sidecar/slskd/upstream.ts +++ b/src/servers/sidecar/slskd/upstream.ts @@ -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(); + +/** + * 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 { + 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 => + 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([ diff --git a/src/servers/sidecar/transmission/config.ts b/src/servers/sidecar/transmission/config.ts new file mode 100644 index 00000000..e3ab8b19 --- /dev/null +++ b/src/servers/sidecar/transmission/config.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/src/servers/sidecar/transmission/index.ts b/src/servers/sidecar/transmission/index.ts index 6d26c22e..b3097521 100644 --- a/src/servers/sidecar/transmission/index.ts +++ b/src/servers/sidecar/transmission/index.ts @@ -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": ""}` 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; diff --git a/src/servers/sidecar/transmission/routes.ts b/src/servers/sidecar/transmission/routes.ts index c0a011ee..461d3b4a 100644 --- a/src/servers/sidecar/transmission/routes.ts +++ b/src/servers/sidecar/transmission/routes.ts @@ -128,16 +128,10 @@ function pick(body: Record, allowed: Set): Record { - 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 { 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('session-get'); + const session = await rpc(ctx.userId, 'session-get'); return Response.json({ session }); } @@ -180,10 +174,10 @@ async function handleSession(ctx: OfficerContext, rest: string[]): Promise('session-get'); + const session = await rpc(ctx.userId, 'session-get'); return Response.json({ session }); } @@ -192,7 +186,7 @@ async function handleSession(ctx: OfficerContext, rest: string[]): Promise { if (ctx.req.method !== 'GET') return methodNotAllowed(); - const stats = await rpc('session-stats'); + const stats = await rpc(ctx.userId, 'session-stats'); return Response.json({ stats }); } @@ -200,19 +194,19 @@ async function handleFreeSpace(ctx: OfficerContext): Promise { 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 { 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 { 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('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('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 { - const body = await readJson(req); +async function addTorrent(ctx: OfficerContext): Promise { + const body = await readJson(ctx.req); if (!body) return badRequest('expected a JSON object body'); const args: Record = {}; @@ -281,7 +275,7 @@ async function addTorrent(req: Request): Promise { 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 { return Response.json({ status: 'added', torrent: null }); } -async function runAction(req: Request): Promise { - const body = await readJson(req); +async function runAction(ctx: OfficerContext): Promise { + 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 { 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 { - const body = await readJson(req); +async function setTorrent(ctx: OfficerContext): Promise { + 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 { 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 { - const body = await readJson(req); +async function setLocation(ctx: OfficerContext): Promise { + 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 { // `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 { - const body = await readJson(req); +async function renamePath(ctx: OfficerContext): Promise { + 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 { 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 { - const body = await readJson(req); +async function removeTorrents(ctx: OfficerContext): Promise { + 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 { 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 }); } diff --git a/src/servers/sidecar/transmission/rpc.ts b/src/servers/sidecar/transmission/rpc.ts index 29cd6f7a..477569bf 100644 --- a/src/servers/sidecar/transmission/rpc.ts +++ b/src/servers/sidecar/transmission/rpc.ts @@ -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(); type RpcResponse = { 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(method: string, args: Record = {}): Promise { - return call(method, args, true); +/** Issue one RPC call against the owner's configured daemon. */ +export async function rpc(userId: number, method: string, args: Record = {}): Promise { + const cfg = await getTransmissionConfig(userId); + if (!cfg) throw new TransmissionError(503, 'transmission is not connected'); + return call(cfg, method, args, true); } -async function call(method: string, args: Record, mayRetry: boolean): Promise { - 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( + cfg: TransmissionConfig, + method: string, + args: Record = {}, +): Promise { + return call(cfg, method, args, true); +} + +async function call( + cfg: TransmissionConfig, + method: string, + args: Record, + mayRetry: boolean, +): Promise { + const endpoint = `${cfg.base}${cfg.rpcPath}`; const headers: Record = { '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(method: string, args: Record, mayRetry: if (res.status === 409) { const fresh = res.headers.get('X-Transmission-Session-Id'); if (fresh && mayRetry) { - sessionId = fresh; - return call(method, args, false); + sessionIds.set(endpoint, fresh); + return call(cfg, method, args, false); } throw new TransmissionError(502, 'transmission rejected the session id handshake'); } @@ -85,10 +99,12 @@ async function call(method: string, args: Record, 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'] }; diff --git a/src/servers/sidecar/transmission/upstream.ts b/src/servers/sidecar/transmission/upstream.ts index b7ec516b..d56e5fe5 100644 --- a/src/servers/sidecar/transmission/upstream.ts +++ b/src/servers/sidecar/transmission/upstream.ts @@ -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(); + +/** + * The owner's Transmission connection, or null when nothing is configured — the sidecar then answers 503, + * and the UI turns that into the setup form rather than an empty torrent list. + */ +export async function getTransmissionConfig(userId: number): Promise { + const hit = cache.get(userId); + if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; + + const creds = await getServiceCredentials(userId, 'transmission'); + 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); } diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekConnection.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekConnection.tsx new file mode 100644 index 00000000..1548006d --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekConnection.tsx @@ -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 & { url: string }; + +export const SoulseekConnection = () => { + const { data, isLoading } = useServiceConnection('slskd'); + const { data: health } = useServiceHealth('slskd'); + const { save, forget } = useServiceConnectionActions('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 ( +
+ Loading… +
+ ); + } + + return ( +
+
+
+
+ +
+
+

slskd daemon

+

+ {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.'} +

+
+
+ + {connection && } + +
+ + + + + {error && ( +
+ + {error} +
+ )} + +
+ + {connection && ( + + )} +
+
+
+
+ ); +}; + +type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined }; + +const StatusRow = ({ connection, health }: StatusRowProps) => ( +
+ {health?.ok ? ( + + ) : ( + + )} +
+
{health?.ok ? 'Connected' : 'Not responding'}
+
+ {connection.url} + {connection.version ? ` · slskd ${connection.version}` : ''} +
+ {!health?.ok && health?.error &&
{health.error}
} +
+
+); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx index 1834649e..1fb04af4 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx @@ -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 = { chat: MessageCircle, users: Users, system: Server, + connection: Plug, }; export const SoulseekNav = () => { @@ -50,7 +61,9 @@ export const SoulseekNav = () => { {active && ( )} - + {label} ); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx index 5c582569..2f6dbac4 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx @@ -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 ; case 'system': return ; + case 'connection': + return ; default: return ; } @@ -59,8 +63,13 @@ type SoulseekViewProps = { panelId: string }; export const SoulseekView = ({ panelId }: SoulseekViewProps) => { const [section] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); const { value: zoom } = useDashboardState(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 ; + if (z === 1) return
{sectionView(section)}
; // transform: scale doesn't reflow, so size the box to 1/z and let the scale bring it back to 100%. diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index 771d47ee..f095afab 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -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 diff --git a/src/workspaces/officerdev/src/apps/Transmission/ConnectionView.tsx b/src/workspaces/officerdev/src/apps/Transmission/ConnectionView.tsx new file mode 100644 index 00000000..91996cd4 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Transmission/ConnectionView.tsx @@ -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) => ( + +); + +type SaveInput = Record & { url: string }; + +export const ConnectionView = () => { + const { data, isLoading } = useServiceConnection('transmission'); + const { data: health } = useServiceHealth('transmission'); + const { save, forget } = useServiceConnectionActions('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 ( +
+ Loading… +
+ ); + } + + return ( +
+
+
+
+ +
+
+

Transmission daemon

+

+ {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.'} +

+
+
+ + {connection && } + +
+ + + + + {showAdvanced && ( + <> + + + + + )} + + {error && ( +
+ + {error} +
+ )} + +
+ + {connection && ( + + )} +
+
+
+
+ ); +}; + +type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined }; + +const StatusRow = ({ connection, health }: StatusRowProps) => ( +
+ {health?.ok ? ( + + ) : ( + + )} +
+
{health?.ok ? 'Connected' : 'Not responding'}
+
+ {connection.url} + {connection.version ? ` · Transmission ${connection.version}` : ''} +
+ {!health?.ok && health?.error &&
{health.error}
} +
+
+); diff --git a/src/workspaces/officerdev/src/apps/Transmission/TorrentsView.tsx b/src/workspaces/officerdev/src/apps/Transmission/TorrentsView.tsx index 322c83e1..92bddedf 100644 --- a/src/workspaces/officerdev/src/apps/Transmission/TorrentsView.tsx +++ b/src/workspaces/officerdev/src/apps/Transmission/TorrentsView.tsx @@ -133,8 +133,8 @@ export const TorrentsView = () => {
Cannot reach Transmission

- 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.

); diff --git a/src/workspaces/officerdev/src/apps/Transmission/TransmissionNav.tsx b/src/workspaces/officerdev/src/apps/Transmission/TransmissionNav.tsx index 51b4fbf9..cb14d22b 100644 --- a/src/workspaces/officerdev/src/apps/Transmission/TransmissionNav.tsx +++ b/src/workspaces/officerdev/src/apps/Transmission/TransmissionNav.tsx @@ -10,6 +10,7 @@ import { Folder, Gauge, ListChecks, + Plug, Radio, Settings, Tag, @@ -33,6 +34,7 @@ const ICONS: Record = { 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'; diff --git a/src/workspaces/officerdev/src/apps/Transmission/TransmissionView.tsx b/src/workspaces/officerdev/src/apps/Transmission/TransmissionView.tsx index ff4f5956..93901aad 100644 --- a/src/workspaces/officerdev/src/apps/Transmission/TransmissionView.tsx +++ b/src/workspaces/officerdev/src/apps/Transmission/TransmissionView.tsx @@ -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 ; switch (section) { + case 'connection': + return ; case 'stats': return ; case 'settings': diff --git a/src/workspaces/officerdev/src/apps/Transmission/shared.ts b/src/workspaces/officerdev/src/apps/Transmission/shared.ts index 81bb614b..f2d3b3b5 100644 --- a/src/workspaces/officerdev/src/apps/Transmission/shared.ts +++ b/src/workspaces/officerdev/src/apps/Transmission/shared.ts @@ -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']; diff --git a/src/workspaces/officerdev/src/hooks/index.ts b/src/workspaces/officerdev/src/hooks/index.ts index b0539143..b38c0dc9 100644 --- a/src/workspaces/officerdev/src/hooks/index.ts +++ b/src/workspaces/officerdev/src/hooks/index.ts @@ -2,3 +2,4 @@ export * from './useFilesAPI'; export * from './useFileViewerPanels'; export * from './useChat'; export * from './useDock'; +export * from './useServiceConnection'; diff --git a/src/workspaces/officerdev/src/hooks/useServiceConnection.ts b/src/workspaces/officerdev/src/hooks/useServiceConnection.ts new file mode 100644 index 00000000..7b2cf586 --- /dev/null +++ b/src/workspaces/officerdev/src/hooks/useServiceConnection.ts @@ -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 `//_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 `//_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(`/${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 => { + try { + return await get(`/${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>(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(`/${service}/_config`), + onSuccess: invalidate, + }); + + return { save, forget }; +}