transmission, extracted into plugins/transmission — the platform half

Sixth extraction, straight after photos. Everything transmission now lives at
gitea.officer.dev/plugins/transmission.

Deleted, not commented: the sidecar, the api router, the Transmission app (28
files including detail/ and dialogs/), the screen, the App.tsx route pair, the
Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the
officerdev barrel re-exports, the permission registry entry, the hono.ts import
and mount comments, and the app-store catalogue entry.

No database change: transmission owns no tables. Its connection lives in
service_connections, a core table it now depends on from outside — a real
coupling, recorded in the plugin's own PLUGIN.md rather than left to be
discovered.

── Test fixtures, repointed rather than deleted ──

Transmission was the canonical `members: 'none'` fixture — a single-tenant
daemon with no user concept — across three app-store test files. slskd has the
identical shape, so the assertions moved there. Deleting them would have
quietly dropped coverage of the no-members install path along with the plugin,
which is the same trap the vault extraction hit with `members: 'invite'`.

── Vocabulary swept, per the runbook ──

Six screens and two App.tsx comments pointed at `/photos` or `/transmission` as
the worked example of the Workspace/Panel shape. Both are plugins now, so those
pointers dangled at code no longer in the tree. Repointed at /command-hq and
/soulseek, which are core.

bunx tsgo clean. 1080 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:19:48 +00:00
co-authored by Claude Opus 5
parent a327dfc82c
commit f8028364f1
56 changed files with 41 additions and 5627 deletions
-17
View File
@@ -1,17 +0,0 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// /api/transmission/* — auth, then forward to officer-transmission. No routes of its own and no transmission knowledge:
// this file must never grow app logic.
//
// The sidecar owns the Transmission RPC contract, including its session-id handshake, and holds the
// credentials. The platform knows none of it.
const proxy = createSidecarProxy({
name: 'transmission',
prefix: '/api/transmission',
});
export const transmissionRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getTransmissionServerUrl = proxy.getHttpUrl;
+4 -3
View File
@@ -107,9 +107,10 @@ describe('member provisioning is declared for every entry', () => {
});
it('never promises accounts for a single-tenant daemon', () => {
// Transmission and slskd have no user concept; claiming otherwise would make the installer try to
// create accounts against an API that does not exist.
for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none');
// slskd has no user concept; claiming otherwise would make the installer try to create accounts
// against an API that does not exist. (Transmission was the other example until 2026-08-18, when it
// became `plugins/transmission/`.)
for (const id of ['slskd']) expect(byId(id)!.members).toBe('none');
});
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
+5 -42
View File
@@ -221,48 +221,11 @@ export const CATALOGUE: CatalogueEntry[] = [
// gets a row there (its install state is `plugin_installs`), so a leftover entry would make the
// permission permanently `unavailable`, put its route into `deniedRoutes`, and withhold the dock tile —
// on a server where the plugin is installed and working.
{
id: 'transmission',
ui: {
name: 'Transmission',
icon: 'ArrowDownUp',
color: '#e11d48',
rootRoute: '/transmission',
routes: ['/transmission'],
},
process: 'officer-transmission',
label: 'Transmission',
summary: 'Torrents, with the daemon Officer talks to over RPC',
members: 'none',
modes: ['existing', 'provisioned'],
permission: 'transmission',
composeTemplate: 'transmission',
existingFields: [
{
key: 'url',
label: 'Transmission URL',
type: 'url',
required: true,
placeholder: 'https://transmission.example.com or http://10.0.0.5:9091',
},
{
key: 'path',
label: 'RPC path',
type: 'text',
required: false,
placeholder: '/transmission/rpc',
help: 'Only differs behind a reverse proxy.',
},
{
key: 'username',
label: 'RPC username',
type: 'text',
required: false,
help: 'Usually blank — Transmission is normally run with no RPC auth.',
},
{ key: 'secret', label: 'RPC password', type: 'secret', required: false },
],
},
// Transmission was here until 2026-08-18, when it became `plugins/transmission/`.
//
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
{
id: 'slskd',
ui: {
+2 -2
View File
@@ -7,7 +7,7 @@ import { byId } from './catalogue';
// when something goes wrong: resume, blocking, and not doing work twice.
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
const slskd = byId('slskd')!; // existing|provisioned, members: 'none'
const configOnly = byId('notify')!; // config only
/** Records what was actually called, so a test can assert on absence as well as presence. */
@@ -46,7 +46,7 @@ describe('planSteps', () => {
it('omits the members step for a service with no user concept', () => {
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
// silent failure to anyone debugging why a member has no access.
expect(planSteps(transmission, 'provisioned')).not.toContain('members');
expect(planSteps(slskd, 'provisioned')).not.toContain('members');
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
});
+2 -2
View File
@@ -12,13 +12,13 @@ describe('what adding a member would do', () => {
it('predicts the outcome per service without contacting anything', () => {
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
expect(plannedOutcome(inviteOnly)).toBe('invited');
expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable');
expect(plannedOutcome(byId('slskd')!)).toBe('not-applicable');
});
});
describe('which installed services have work to do', () => {
it('skips single-tenant daemons entirely', () => {
const ids = servicesNeedingMemberProvision(['jellyfin', 'transmission', 'slskd', 'memos']).map((e) => e.id);
const ids = servicesNeedingMemberProvision(['jellyfin', 'slskd', 'memos']).map((e) => e.id);
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
});
-2
View File
@@ -25,7 +25,6 @@ import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
// import { slskdRouter } from './api/slskd/router';
// import { transmissionRouter } from './api/transmission/router';
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
// import { jellyfinRouter } from './api/jellyfin/router';
// import { walletRouter } from './api/wallet/router';
@@ -151,7 +150,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
// ['/transmission', transmissionRouter], // plugin — switched off 2026-08-13
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
-9
View File
@@ -200,15 +200,6 @@ const CORE_REGISTRY: Permission[] = [
// Device registration is the caller's own — a phone subscribing to its own push channel.
personal: ['/devices', '/subscriptions'],
},
{
key: 'transmission',
label: 'Transmission',
description: 'Torrent downloads',
kind: 'app',
// api: ['/transmission'], // plugin — switched off 2026-08-13
api: [],
routes: ['/transmission'],
},
{
key: 'soulseek',
label: 'Soulseek',
+20
View File
@@ -129,6 +129,26 @@ const BUNDLED: CatalogueEntry[] = [
// No osDependencies: the sidecar is a Bun HTTP proxy and reaches Immich with `fetch`.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'transmission',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Transmission',
summary: 'Torrent downloads — queue, files, peers and speed, from your own daemon',
color: '#dc2626',
icon: 'Download',
source: 'https://gitea.officer.dev/plugins/transmission.git',
permissions: [
{
key: 'transmission',
label: 'Transmission',
description: 'Torrent downloads',
},
],
// `schema: false` — the connection lives in `service_connections`, a core table. See its manifest.
has: { api: true, schema: false, sidecar: true, web: true },
},
{
appName: 'vault',
publisher: 'officerdev',
@@ -1,91 +0,0 @@
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<Response> {
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<Response> {
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<Response> {
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);
}
-156
View File
@@ -1,156 +0,0 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleConfigRoute } from './config';
import { handleOfficerRoute } from './routes';
import { probe } from './rpc';
import { getTransmissionConfig } from './upstream';
import { API_URL } from '../../officer-url.mjs';
// 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
// single-endpoint RPC vocabulary into real REST paths. The platform API is a thin auth-gated forwarder
// (src/servers/api/transmission/router.ts) holding no Transmission credentials.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// 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
// GET /_officer/torrents the list poll — LIST_FIELDS for every torrent, decorated
// GET /_officer/torrents/:id one torrent with files/peers/trackers (DETAIL_FIELDS)
// POST /_officer/torrents/add {metainfo|filename, downloadDir?, labels?, paused?, …}
// → {status:'added'|'duplicate', torrent}
// POST /_officer/torrents/action {ids, action} — start|start-now|stop|verify|reannounce|queue-*
// POST /_officer/torrents/set {ids, …whitelisted torrent-set fields}
// POST /_officer/torrents/location {ids, location, move}
// POST /_officer/torrents/rename {id, path, name}
// POST /_officer/torrents/remove {ids, deleteLocalData}
// GET /_officer/free-space?path= free space at a path
// GET /_officer/port-test is the peer port reachable from outside
// POST /_officer/blocklist-update refresh the blocklist, returns the new size
// anything else 404
//
// There is deliberately NO transparent /transmission/rpc passthrough. Transmission multiplexes every verb
// through one POST body, reports failures as `{"result": "<error>"}` 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.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
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;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// A base64 .torrent for a large multi-file release travels in the request body of /torrents/add.
maxRequestBodySize: 256 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
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(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(userId, req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
// 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;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'transmission',
handles: ['transmission'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'transmission:server', port });
console.log(`[transmission] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[transmission] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-367
View File
@@ -1,367 +0,0 @@
import type { SessionSettings, SessionStats, Torrent } from './types';
import { DETAIL_FIELDS, LIST_FIELDS } from './types';
import { decorate, rpc, TransmissionError } from './rpc';
// Officer-owned routes for the transmission sidecar — the entire feature surface lives under /_officer/.
//
// Nothing here is a passthrough. Transmission speaks a single POST endpoint with a `method` field, which is
// a terrible fit for an HTTP cache, for auth scoping and for a browser client: it would mean shipping the
// 409 handshake, the `result: 'success'` convention and the whole method vocabulary into the SPA. So the
// verbs are re-expressed as real REST paths and every quirk is absorbed in rpc.ts.
export type OfficerContext = { req: Request; url: URL; userId: number };
/** 400 with a machine-readable reason. */
export const badRequest = (error: string) => Response.json({ error }, { status: 400 });
/** 404 for an unknown /_officer/ path or a missing object. */
export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 });
/** 405 when the path exists but the verb doesn't. */
export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 });
async function readJson(req: Request): Promise<Record<string, unknown> | null> {
const body = await req.json().catch(() => null);
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
}
/** Torrent ids as Transmission wants them: a number array. Rejects anything that isn't a positive integer. */
function readIds(body: Record<string, unknown>): number[] | null {
const raw = body['ids'];
if (!Array.isArray(raw)) return null;
const ids = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0);
return ids.length === raw.length ? ids : null;
}
// Actions that take {ids} and nothing else. Mapping them by name keeps the browser from naming RPC methods.
const ACTIONS: Record<string, string> = {
start: 'torrent-start',
'start-now': 'torrent-start-now',
stop: 'torrent-stop',
verify: 'torrent-verify',
reannounce: 'torrent-reannounce',
'queue-top': 'queue-move-top',
'queue-up': 'queue-move-up',
'queue-down': 'queue-move-down',
'queue-bottom': 'queue-move-bottom',
};
// Whitelists, not passthroughs. Transmission silently accepts some nonsense and hard-fails on other, so the
// browser is never allowed to name an arbitrary RPC argument — a typo here is a 400, not a silent no-op.
const TORRENT_SET_FIELDS = new Set([
'bandwidthPriority',
'downloadLimit',
'downloadLimited',
'files-unwanted',
'files-wanted',
'group',
'honorsSessionLimits',
'labels',
'peer-limit',
'priority-high',
'priority-low',
'priority-normal',
'queuePosition',
'seedIdleLimit',
'seedIdleMode',
'seedRatioLimit',
'seedRatioMode',
'sequential_download',
'trackerAdd',
'trackerList',
'uploadLimit',
'uploadLimited',
]);
const SESSION_SET_FIELDS = new Set([
'alt-speed-down',
'alt-speed-enabled',
'alt-speed-time-begin',
'alt-speed-time-day',
'alt-speed-time-enabled',
'alt-speed-time-end',
'alt-speed-up',
'blocklist-enabled',
'blocklist-url',
'cache-size-mb',
'default-trackers',
'dht-enabled',
'download-dir',
'download-queue-enabled',
'download-queue-size',
'encryption',
'idle-seeding-limit',
'idle-seeding-limit-enabled',
'incomplete-dir',
'incomplete-dir-enabled',
'lpd-enabled',
'peer-limit-global',
'peer-limit-per-torrent',
'peer-port',
'peer-port-random-on-start',
'pex-enabled',
'port-forwarding-enabled',
'queue-stalled-enabled',
'queue-stalled-minutes',
'rename-partial-files',
'script-torrent-done-enabled',
'script-torrent-done-filename',
'seed-queue-enabled',
'seed-queue-size',
'seedRatioLimit',
'seedRatioLimited',
'speed-limit-down',
'speed-limit-down-enabled',
'speed-limit-up',
'speed-limit-up-enabled',
'start-added-torrents',
'trash-original-torrent-files',
'utp-enabled',
]);
function pick(body: Record<string, unknown>, allowed: Set<string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body)) {
if (allowed.has(key)) out[key] = value;
}
return out;
}
/**
* Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
*
* `userId` was authenticated by the platform proxy and validated at the server boundary — every call below
* needs it, because which daemon to talk to is per-owner state read from the database.
*/
export async function handleOfficerRoute(userId: number, req: Request, url: URL): Promise<Response | null> {
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
if (segments.length === 0) return null;
const ctx: OfficerContext = { req, url, userId };
try {
switch (segments[0]) {
case 'session':
return await handleSession(ctx, segments.slice(1));
case 'stats':
return await handleStats(ctx);
case 'torrents':
return await handleTorrents(ctx, segments.slice(1));
case 'free-space':
return await handleFreeSpace(ctx);
case 'port-test':
return await handlePortTest(ctx);
case 'blocklist-update':
return await handleBlocklistUpdate(ctx);
default:
return null;
}
} catch (err) {
if (err instanceof TransmissionError) return Response.json({ error: err.message }, { status: err.status });
throw err;
}
}
async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
if (rest.length > 0) return null;
if (ctx.req.method === 'GET') {
// No `fields` argument: session-get returns everything, and the settings UI reads nearly all of it.
const session = await rpc<SessionSettings>(ctx.userId, 'session-get');
return Response.json({ session });
}
if (ctx.req.method === 'POST') {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const args = pick(body, SESSION_SET_FIELDS);
if (Object.keys(args).length === 0) return badRequest('no writable session fields in body');
await rpc(ctx.userId, 'session-set', args);
// Read back rather than echoing the request: Transmission clamps and normalises several of these
// (alt-speed times, queue sizes), so the request body is not what the daemon ends up holding.
const session = await rpc<SessionSettings>(ctx.userId, 'session-get');
return Response.json({ session });
}
return methodNotAllowed();
}
async function handleStats(ctx: OfficerContext): Promise<Response> {
if (ctx.req.method !== 'GET') return methodNotAllowed();
const stats = await rpc<SessionStats>(ctx.userId, 'session-stats');
return Response.json({ stats });
}
async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
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 }>(ctx.userId, 'free-space', { path });
return Response.json({ path: result.path, bytes: result['size-bytes'] });
}
// GET: it asks the daemon whether the peer port is reachable and returns the answer. Nothing changes,
// on this side or Transmission's. Blocklist-update below stays POST because it genuinely refetches and
// replaces the list — the two look alike and are not.
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
if (ctx.req.method !== 'GET') return methodNotAllowed();
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<Response> {
if (ctx.req.method !== 'POST') return methodNotAllowed();
const result = await rpc<{ 'blocklist-size': number }>(ctx.userId, 'blocklist-update');
return Response.json({ size: result['blocklist-size'] });
}
async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
const { req } = ctx;
// GET /_officer/torrents — the list poll.
if (rest.length === 0 && req.method === 'GET') {
const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', { fields: [...LIST_FIELDS] });
return Response.json({ torrents: result.torrents.map(decorate) });
}
if (rest.length === 1) {
const [segment] = rest as [string];
if (req.method === 'POST') {
switch (segment) {
case 'add':
return await addTorrent(ctx);
case 'action':
return await runAction(ctx);
case 'set':
return await setTorrent(ctx);
case 'location':
return await setLocation(ctx);
case 'rename':
return await renamePath(ctx);
case 'remove':
return await removeTorrents(ctx);
}
}
// GET /_officer/torrents/:id — the detail poll, one torrent, with the expensive arrays.
const id = Number(segment);
if (!Number.isInteger(id) || id <= 0) return notFound();
if (req.method !== 'GET') return methodNotAllowed();
const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', {
ids: [id],
fields: [...LIST_FIELDS, ...DETAIL_FIELDS],
});
const torrent = result.torrents[0];
if (!torrent) return notFound('torrent not found');
return Response.json({ torrent: decorate(torrent) });
}
return null;
}
async function addTorrent(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const args: Record<string, unknown> = {};
// Exactly one source. `metainfo` is a base64 .torrent; `filename` is a magnet link or an http(s) URL.
if (typeof body['metainfo'] === 'string' && body['metainfo']) args['metainfo'] = body['metainfo'];
else if (typeof body['filename'] === 'string' && body['filename']) args['filename'] = body['filename'];
else return badRequest('either metainfo or filename is required');
if (typeof body['downloadDir'] === 'string' && body['downloadDir']) args['download-dir'] = body['downloadDir'];
if (Array.isArray(body['labels'])) args['labels'] = body['labels'].filter((l) => typeof l === 'string');
if (typeof body['paused'] === 'boolean') args['paused'] = body['paused'];
if (typeof body['bandwidthPriority'] === 'number') args['bandwidthPriority'] = body['bandwidthPriority'];
if (typeof body['sequentialDownload'] === 'boolean') args['sequential_download'] = body['sequentialDownload'];
const result = await rpc<{
'torrent-added'?: { id: number; name: string; hashString: string };
'torrent-duplicate'?: { id: number; name: string; hashString: string };
}>(ctx.userId, 'torrent-add', args);
const added = result['torrent-added'];
const duplicate = result['torrent-duplicate'];
// A duplicate is not an error — Transmission reports the torrent you already had, and the UI counts it
// separately so a batch add can say "3 added, 1 already present" instead of failing opaquely.
if (duplicate) return Response.json({ status: 'duplicate', torrent: duplicate });
if (added) return Response.json({ status: 'added', torrent: added });
return Response.json({ status: 'added', torrent: null });
}
async function runAction(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const action = typeof body['action'] === 'string' ? body['action'] : '';
const method = ACTIONS[action];
if (!method) return badRequest(`unknown action "${action}"`);
const ids = readIds(body);
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(ctx.userId, method, { ids });
return Response.json({ ok: true, affected: ids.length });
}
async function setTorrent(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const ids = readIds(body);
if (!ids) return badRequest('ids must be an array of positive integers');
const fields = pick(body, TORRENT_SET_FIELDS);
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(ctx.userId, 'torrent-set', { ids, ...fields });
return Response.json({ ok: true, affected: ids.length });
}
async function setLocation(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const ids = readIds(body);
if (!ids) return badRequest('ids must be an array of positive integers');
const location = typeof body['location'] === 'string' ? body['location'].trim() : '';
if (!location) return badRequest('location is required');
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
// `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(ctx.userId, 'torrent-set-location', { ids, location, move });
return Response.json({ ok: true, affected: ids.length });
}
async function renamePath(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const id = Number(body['id']);
if (!Number.isInteger(id) || id <= 0) return badRequest('id must be a positive integer');
const path = typeof body['path'] === 'string' ? body['path'] : '';
const name = typeof body['name'] === 'string' ? body['name'].trim() : '';
if (!path) return badRequest('path is required');
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(ctx.userId, 'torrent-rename-path', { ids: [id], path, name });
return Response.json({ ok: true });
}
async function removeTorrents(ctx: OfficerContext): Promise<Response> {
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON object body');
const ids = readIds(body);
if (!ids) return badRequest('ids must be an array of positive integers');
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
const deleteLocalData = body['deleteLocalData'] === true;
await rpc(ctx.userId, 'torrent-remove', { ids, 'delete-local-data': deleteLocalData });
return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData });
}
-165
View File
@@ -1,165 +0,0 @@
import type { Torrent, TrackerStat } from './types';
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:
//
// • THE 409 HANDSHAKE. Transmission's CSRF defence answers the first request of a session with
// `409 Conflict` and an `X-Transmission-Session-Id` header, expecting the client to repeat the request
// carrying it. The token also rotates whenever the daemon restarts, so this is not a start-up ritual you
// can do once — any call can 409 at any time. Absorbing it here is the single biggest reason this
// sidecar exists: a client that handles it naively works until the daemon is restarted and then fails
// 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 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.
const DEFAULT_TIMEOUT_MS = 30_000;
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
export class TransmissionError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
this.name = 'TransmissionError';
}
}
// 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<string, string>();
type RpcResponse<T> = { result: string; arguments?: T };
/** Issue one RPC call against the owner's configured daemon. */
export async function rpc<T = unknown>(userId: number, method: string, args: Record<string, unknown> = {}): Promise<T> {
const cfg = await getTransmissionConfig(userId);
if (!cfg) throw new TransmissionError(503, 'transmission is not connected');
return call<T>(cfg, method, args, true);
}
/** The same call against a connection that may not be stored yet — used to validate one before saving it. */
export async function rpcWith<T = unknown>(
cfg: TransmissionConfig,
method: string,
args: Record<string, unknown> = {},
): Promise<T> {
return call<T>(cfg, method, args, true);
}
async function call<T>(
cfg: TransmissionConfig,
method: string,
args: Record<string, unknown>,
mayRetry: boolean,
): Promise<T> {
const endpoint = `${cfg.base}${cfg.rpcPath}`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const sessionId = sessionIds.get(endpoint);
if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
if (cfg.auth) headers['Authorization'] = cfg.auth;
let res: Response;
try {
res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({ method, arguments: args }),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
} catch (err) {
const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'unreachable';
throw new TransmissionError(502, `transmission upstream ${reason}`);
}
if (res.status === 409) {
const fresh = res.headers.get('X-Transmission-Session-Id');
if (fresh && mayRetry) {
sessionIds.set(endpoint, fresh);
return call<T>(cfg, method, args, false);
}
throw new TransmissionError(502, 'transmission rejected the session id handshake');
}
if (res.status === 401) throw new TransmissionError(401, 'transmission rejected the credentials');
if (!res.ok) throw new TransmissionError(502, `transmission returned ${res.status}`);
const body = (await res.json().catch(() => null)) as RpcResponse<T> | null;
if (!body) throw new TransmissionError(502, 'transmission returned an unreadable body');
// A 200 with a non-'success' result is the normal way Transmission reports a failure.
if (body.result !== 'success') throw new TransmissionError(502, body.result || 'transmission reported a failure');
return (body.arguments ?? {}) as T;
}
/** 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 rpcWith<{ version: string; 'rpc-version': number }>(cfg, 'session-get', {
fields: ['version', 'rpc-version'],
});
return { ok: true, version: args.version, rpcVersion: args['rpc-version'] };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
// ── Derived fields ────────────────────────────────────────────────────────────────────────────────
// Subdomain prefixes that carry no identity — `tracker.example.org` and `t.example.org` are the same
// operator to a human grouping their torrents, and showing them as two entries makes the filter useless.
const IGNORED_TRACKER_PREFIXES = new Set(['t', 'tr', 'tk', 'tracker', 'bt', 'open', 'opentracker', 'pt']);
const ANNOUNCE_STATE: Record<number, string> = {
0: 'Inactive',
1: 'Waiting',
2: 'Queued',
3: 'Announcing',
};
/** `udp://tr.example.org:1337/announce` → `example.org`. Empty string when there is no tracker at all. */
export function trackerHost(stats: TrackerStat[]): string {
const primary = stats.find((t) => !t.isBackup) ?? stats[0];
if (!primary) return '';
const raw = primary.host || primary.announce;
let host: string;
try {
host = new URL(raw.includes('://') ? raw : `http://${raw}`).hostname;
} catch {
host = raw
.replace(/^[a-z]+:\/\//i, '')
.split('/')[0]!
.split(':')[0]!;
}
const parts = host.split('.');
// Only strip a prefix when something recognisable remains — `t.co` must not become `co`.
if (parts.length > 2 && IGNORED_TRACKER_PREFIXES.has(parts[0]!.toLowerCase())) parts.shift();
return parts.join('.');
}
function trackerStatus(stats: TrackerStat[]): string {
const primary = stats.find((t) => !t.isBackup) ?? stats[0];
if (!primary) return '';
if (primary.lastAnnounceSucceeded === false && primary.lastAnnounceResult) return primary.lastAnnounceResult;
return ANNOUNCE_STATE[primary.announceState] ?? '';
}
/** Attach the `officer*` fields the UI groups and filters by. Mutating the parsed body is fine — it's ours. */
export function decorate(torrent: Torrent): Torrent {
const stats = torrent.trackerStats ?? [];
torrent.officerTrackerHost = trackerHost(stats);
torrent.officerTrackerStatus = trackerStatus(stats);
torrent.officerTrackerErrors = stats.filter((t) => t.lastAnnounceSucceeded === false && t.hasAnnounced).length;
return torrent;
}
-244
View File
@@ -1,244 +0,0 @@
// Wire types for the Transmission RPC surface this sidecar uses.
//
// Field names are Transmission's OWN, verbatim — including its inconsistent casing (`percentDone` next to
// `begin_piece`, `peer-limit`, `sequential_download`). That is deliberate: renaming them would mean every
// future field needs a translation entry, and a name that doesn't appear in Transmission's rpc-spec.md is a
// name you cannot grep for when something misbehaves. The only fields we invent are the `officer*` derived
// ones at the bottom of Torrent, which are prefixed so they can never be mistaken for upstream data.
/** Transmission's torrent status enum (rpc-spec.md §3.3). */
export const TorrentStatus = {
Stopped: 0,
CheckWait: 1,
Check: 2,
DownloadWait: 3,
Download: 4,
SeedWait: 5,
Seed: 6,
} as const;
export type TrackerStat = {
announce: string;
announceState: number;
downloadCount: number;
hasAnnounced: boolean;
hasScraped: boolean;
host: string;
id: number;
isBackup: boolean;
lastAnnouncePeerCount: number;
lastAnnounceResult: string;
lastAnnounceSucceeded: boolean;
lastAnnounceTime: number;
lastScrapeResult: string;
lastScrapeSucceeded: boolean;
lastScrapeTime: number;
leecherCount: number;
nextAnnounceTime: number;
scrape: string;
seederCount: number;
tier: number;
};
export type TorrentFile = {
bytesCompleted: number;
length: number;
name: string;
};
export type TorrentFileStat = {
bytesCompleted: number;
priority: number;
wanted: boolean;
};
export type TorrentPeer = {
address: string;
clientName: string;
clientIsChoked: boolean;
clientIsInterested: boolean;
flagStr: string;
isDownloadingFrom: boolean;
isEncrypted: boolean;
isIncoming: boolean;
isUploadingTo: boolean;
isUTP: boolean;
peerIsChoked: boolean;
peerIsInterested: boolean;
port: number;
progress: number;
rateToClient: number;
rateToPeer: number;
};
export type PeersFrom = {
fromCache: number;
fromDht: number;
fromIncoming: number;
fromLpd: number;
fromLtep: number;
fromPex: number;
fromTracker: number;
};
export type Torrent = {
id: number;
name: string;
status: number;
totalSize: number;
sizeWhenDone: number;
leftUntilDone: number;
haveValid: number;
percentDone: number;
metadataPercentComplete: number;
recheckProgress?: number;
downloadedEver: number;
uploadedEver: number;
corruptEver?: number;
uploadRatio: number;
rateDownload: number;
rateUpload: number;
eta: number;
peersSendingToUs: number;
peersGettingFromUs: number;
addedDate: number;
doneDate: number;
activityDate: number;
secondsSeeding: number;
downloadDir: string;
bandwidthPriority: number;
queuePosition: number;
isPrivate: boolean;
labels: string[];
error: number;
errorString: string;
pieceCount: number;
pieceSize: number;
magnetLink: string;
group: string;
'file-count': number;
trackerStats: TrackerStat[];
trackerList?: string;
// Per-torrent limits — the fields the "Other settings" dialog reads and writes.
honorsSessionLimits: boolean;
downloadLimited: boolean;
downloadLimit: number;
uploadLimited: boolean;
uploadLimit: number;
'peer-limit': number;
seedRatioMode: number;
seedRatioLimit: number;
seedIdleMode: number;
seedIdleLimit: number;
sequential_download?: boolean;
// Detail-only fields — present when fetched through /_officer/torrents/:id.
hashString?: string;
comment?: string;
creator?: string;
dateCreated?: number;
maxConnectedPeers?: number;
files?: TorrentFile[];
fileStats?: TorrentFileStat[];
peers?: TorrentPeer[];
peersFrom?: PeersFrom;
// ── Derived by the sidecar, never sent by Transmission ──
/**
* The primary tracker's host with its port and any ignorable subdomain prefix stripped
* (`udp://tr.example.org:1337/announce` → `example.org`). The UI groups by this, so the normalisation
* has to be one implementation, not one per caller.
*/
officerTrackerHost: string;
/** Human-readable announce state of the primary tracker, for the "Tracker Status" column. */
officerTrackerStatus: string;
/** Count of trackers that have reported an error, so the UI can flag a torrent without walking the array. */
officerTrackerErrors: number;
};
export type SessionSettings = Record<string, unknown> & {
version: string;
'rpc-version': number;
'download-dir': string;
};
export type SessionStats = {
activeTorrentCount: number;
pausedTorrentCount: number;
torrentCount: number;
downloadSpeed: number;
uploadSpeed: number;
'cumulative-stats': StatsBucket;
'current-stats': StatsBucket;
};
export type StatsBucket = {
downloadedBytes: number;
uploadedBytes: number;
filesAdded: number;
sessionCount: number;
secondsActive: number;
};
/** The fields the torrent list needs. Mirrors what the list UI actually renders — nothing speculative. */
export const LIST_FIELDS = [
'activityDate',
'addedDate',
'bandwidthPriority',
'corruptEver',
'doneDate',
'downloadDir',
'downloadedEver',
'downloadLimit',
'downloadLimited',
'error',
'errorString',
'eta',
'file-count',
'group',
'haveValid',
'honorsSessionLimits',
'id',
'isPrivate',
'labels',
'leftUntilDone',
'magnetLink',
'metadataPercentComplete',
'name',
'peer-limit',
'peersGettingFromUs',
'peersSendingToUs',
'percentDone',
'pieceCount',
'pieceSize',
'queuePosition',
'rateDownload',
'rateUpload',
'secondsSeeding',
'seedIdleLimit',
'seedIdleMode',
'seedRatioLimit',
'seedRatioMode',
'sequential_download',
'sizeWhenDone',
'status',
'totalSize',
'trackerList',
'trackerStats',
'uploadedEver',
'uploadLimit',
'uploadLimited',
'uploadRatio',
] as const;
/** Extra fields only the detail pane needs — big arrays we refuse to pull for every row on every poll. */
export const DETAIL_FIELDS = [
'comment',
'creator',
'dateCreated',
'fileStats',
'files',
'hashString',
'maxConnectedPeers',
'peers',
'peersFrom',
'recheckProgress',
] as const;
@@ -1,74 +0,0 @@
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);
}