Files
pastilhasandClaude Opus 5 864dd16355 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>
2026-08-18 19:19:30 +00:00

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);
}