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 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:04:22 +00:00
co-authored by Claude Opus 5
parent f20d4a300e
commit d7b775113b
26 changed files with 1235 additions and 175 deletions
+8
View File
@@ -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,
@@ -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<Row, keyof typeof connectionCols>): 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<ServiceConnection | null> {
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<ServiceCredentials | null> {
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<ServiceConnection> {
const { userId, service, url, username, secret, path, version } = params;
const now = new Date();
const set: Partial<Row> = { 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<boolean> {
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<void> {
await db
.update(serviceConnections)
.set({ version, lastSeenAt: new Date() })
.where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service)));
}
@@ -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';
@@ -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)],
);