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>
75 lines
3.6 KiB
TypeScript
75 lines
3.6 KiB
TypeScript
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);
|
|
}
|