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:
@@ -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)],
|
||||
);
|
||||
Reference in New Issue
Block a user