transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and 29 web files; no db/ directory, because there are no tables. ── The one thing that is not self-contained ── The connection lives in `service_connections`, a CORE table, reached through getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin declares no schema. Deliberate: that table is the app store's shared credential store, keyed on (user_id, service), already encrypted at rest and already read by app-store/effects.ts. A private copy of the same three columns would be a second place the same fact could live, and the runbook is explicit that a move plus a data-format change gives any failure two causes. The cost, stated rather than hidden: this plugin depends on a core facility it does not own, and would break if that table changed shape. Photos, by contrast, owns photos_config outright. ── 38 palette classes paid down, mapped by meaning ── blue downloading / download speed -> info cyan checking (verifying) -> busy emerald seeding / complete -> success amber queued / stalled / warnings -> warning Collapsing blue and cyan into one tone would have lost a real distinction in the status dot, so `checking` went to `busy` — in-progress work, which is what that tone is for. ── A near-miss worth recording ── The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/ — ten components. tsgo caught it as ten "cannot find module" errors. Worth stating because it is quiet in the other direction: had those been imported lazily, the typecheck would have passed and the panels would have broken at runtime. Check the directory tree, not the file glob. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { getServiceCredentials } from 'officerdb';
|
||||
|
||||
// Transmission upstream config for the officer-transmission sidecar.
|
||||
//
|
||||
// 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/service-connections/queries.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.
|
||||
|
||||
/** 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 };
|
||||
|
||||
/** 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 stored per connection rather than hardcoded.
|
||||
*/
|
||||
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, which is why the stored username is nullable rather than defaulting to ''.
|
||||
*/
|
||||
export function basicAuth(username: string | null, password: string | null): string | null {
|
||||
const user = username?.trim();
|
||||
if (!user) return null;
|
||||
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');
|
||||
// `url` became nullable when services that inherit one instance landed (see the schema column note).
|
||||
// Transmission fronts a single daemon and always stores its own, so null here is a malformed row.
|
||||
const cfg = creds?.url
|
||||
? {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user