gitea: one instance, a token per person

Gitea is the first service where the server is shared but the account is not.
The owner connects the instance; every other user supplies only their own access
token and sees their own repositories, notifications and issues.

The plumbing was already per-user — createSidecarProxy injects the authenticated
caller's id as X-Officer-User, the sidecar refuses a request without it, and
service_connections is UNIQUE (user_id, service). What was wrong is that url and
token lived in the same row and a save demanded both, so a member would have had
to type the instance URL. That is worse than inconvenient: a member who can name
the URL has a per-user SSRF hop behind a settings form, and the sidecar would
dutifully attach their token to it.

`url` is now nullable, and NULL means "inherit the instance". The owner's row
carries the URL and IS the instance; everyone else's row is a credential. A
member's URL is therefore not stored rather than merely hidden — which is what
makes "members never see the instance URL" a property of the schema instead of a
filter somebody has to remember on every response.

Resolution lives in one place (getResolvedServiceCredentials / getServiceInstanceUrl)
rather than in each sidecar, so there is a single answer to "where is this
service" and no sidecar can accidentally trust a member-supplied URL.

Rules, all enforced in the sidecar rather than the UI, because a form that hides
a field is a suggestion and these are rules:

  owner PUT /_config    { url, token }, as before
  member PUT /_config   { token } only; a url in the body is REJECTED, not
                        ignored — ignoring it would leave someone debugging a
                        screen quietly talking to a different server
  member, no instance   409, "the server owner has not connected a Gitea
                        instance yet"
  member GET /_config   has no url to return
  owner disconnects     members keep their tokens and resolve to nothing; no
                        instance, no service

GET /_config also now answers `instanceConfigured` and `isOwner`, which is what
lets the UI tell "you have not connected yet" from "there is nothing here to
connect to" — different screens.

memos, slskd and transmission front a single daemon and always store their own
URL; they now treat a null as a malformed row rather than reaching for somebody
else's instance.

Verified on a scratch database: pushing twice adds no diff churn beyond the known
pk_music_now_playing pair, and the resolution behaves — member GET returns a null
url, member credentials resolve to the owner's base with the member's own token,
and deleting the owner's row leaves the member's token intact but resolving to
nothing. All four live rows have a url today, so the column change applies
without touching data.

Not reachable by a real member yet: the account backstop still confines
non-owners to /api/auth + /api/music. This works the moment the capability model
lands, and until then is testable only by minting a token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 16:08:54 +00:00
co-authored by Claude Opus 5
parent e54d71da71
commit c9cc5d4a58
8 changed files with 445 additions and 8 deletions
+2
View File
@@ -188,6 +188,8 @@ export {
saveServiceConnection,
deleteServiceConnection,
recordServiceProbe,
getServiceInstanceUrl,
getResolvedServiceCredentials,
} from './queries/service-connections';
export type { ServiceName, ServiceConnection, ServiceCredentials } from './queries/service-connections';
export {
@@ -1,6 +1,7 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { serviceConnections } from '../schema';
import { getOwnerUser } from './auth';
import { encryptSecret, decryptSecret } from '../crypto';
// Single-connection services (transmission, slskd) for their sidecars. Callers deal in PLAINTEXT —
@@ -13,12 +14,13 @@ import { encryptSecret, decryptSecret } from '../crypto';
// 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' | 'esplora' | 'nbxplorer' | 'memos' | 'opengist';
export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer' | 'memos' | 'opengist' | 'gitea';
export type ServiceConnection = {
id: number;
service: string;
url: string;
/** Null on a credential-only row — see the column comment in ../schema/service-connections.ts. */
url: string | null;
username: string | null;
path: string | null;
/** Whether a secret is stored. The secret itself never crosses this boundary, not even masked. */
@@ -30,7 +32,8 @@ export type ServiceConnection = {
export type ServiceCredentials = {
id: number;
url: string;
/** Null when this row inherits the instance — resolve it with getServiceInstanceUrl. */
url: string | null;
username: string | null;
secret: string | null;
path: string | null;
@@ -91,7 +94,12 @@ export async function getServiceCredentials(userId: number, service: ServiceName
type SaveServiceConnectionParams = {
userId: number;
service: ServiceName;
url: string;
/**
* Null writes a CREDENTIAL row — one that inherits the instance from the owner's row on read. Only
* the inheriting kind of service does this, and only for non-owner users. See the column comment in
* ../schema/service-connections.ts.
*/
url: string | null;
username?: string | null;
/** Encrypted before write. Undefined leaves a stored secret alone; null clears it. */
secret?: string | null;
@@ -151,3 +159,43 @@ export async function recordServiceProbe(userId: number, service: ServiceName, v
.set({ version, lastSeenAt: new Date() })
.where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service)));
}
// ── Inherited instances ──
//
// Some services are ONE instance that several people authenticate to individually — Gitea is the first.
// The owner's row carries the URL and is the instance; everyone else's row carries only their own
// credential and leaves `url` null. See the column comment in ../schema/service-connections.ts.
//
// Resolution lives here rather than in each sidecar so there is one answer to "where is this service",
// and so a sidecar cannot accidentally use a member-supplied URL — the member has not got one to supply.
/**
* The instance URL for a service: whoever owns the platform configured it. Null when the owner has not
* connected this service yet, which is the signal to refuse a member's setup rather than let them invent
* an instance of their own.
*/
export async function getServiceInstanceUrl(service: ServiceName): Promise<string | null> {
const owner = await getOwnerUser();
if (!owner) return null;
const [row] = await db
.select({ url: serviceConnections.url })
.from(serviceConnections)
.where(and(eq(serviceConnections.userId, owner.id), eq(serviceConnections.service, service)));
return row?.url ?? null;
}
/**
* This user's credentials with the base resolved: their own URL if they have one (the owner, or a
* single-daemon service), otherwise the instance the owner configured. Returns null when there is no
* usable pairing — no row, or a row that inherits from an instance nobody has set up.
*/
export async function getResolvedServiceCredentials(
userId: number,
service: ServiceName,
): Promise<(ServiceCredentials & { url: string }) | null> {
const own = await getServiceCredentials(userId, service);
if (!own) return null;
if (own.url) return { ...own, url: own.url };
const instance = await getServiceInstanceUrl(service);
return instance ? { ...own, url: instance } : null;
}
@@ -28,7 +28,21 @@ export const serviceConnections = pgTable(
/** '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(),
//
// NULL means "inherit the instance" — the row is a CREDENTIAL, not a connection.
//
// Some services are one instance that several people authenticate to individually: Gitea is the first.
// The owner's row carries the URL and is the instance; every other user's row carries only their own
// access token and resolves the base from the owner's row at read time. A member's URL is therefore not
// stored rather than merely hidden, which is what makes "members never see the instance URL" a property
// of the schema instead of a filter somebody has to remember on every response.
//
// It also closes the obvious hole: if a member could supply a URL, the sidecar would dutifully talk to
// whatever host they named, which is a per-user SSRF hop wearing a settings form.
//
// Single-daemon services (transmission, slskd, nbxplorer) always set it. Only the inheriting kind leaves
// it null, and only for non-owner rows.
url: text('url'),
/** 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
+264
View File
@@ -0,0 +1,264 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import {
getServiceConnection,
saveServiceConnection,
deleteServiceConnection,
recordServiceProbe,
getServiceInstanceUrl,
getOwnerUser,
} from 'officerdb';
import { callGitea, getGiteaConfig, invalidateGiteaConfig, normalizeBase, probe } from './upstream';
// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access
// token. The platform side is a thin auth-gated forwarder holding no Gitea credentials.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding.
//
// GET /_health is the instance up, and does the stored token work
// GET /_config the connection MINUS the token
// PUT/POST /_config { url, token } — validated live before it is stored
// DELETE /_config forget the connection
// ANY /_api/* forwarded to the instance's own /api/*, token attached
//
// That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so
// the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than
// growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a
// save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`.
// Gitea genuinely is one-of: nobody runs two.
//
// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same
// reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor
// versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to
// speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the
// sidecar from being a general-purpose SSRF hop into whatever else is on that host.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
// Everything under /api/v1 the UI legitimately needs.
//
// `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site
// admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint
// tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted
// into a connection form should not silently become a remote root shell for the instance. If an admin
// screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix.
const ALLOWED = [
/^\/api\/v1\/version(\/|$|\?)/,
/^\/api\/v1\/user(\/|$|\?)/,
/^\/api\/v1\/users(\/|$|\?)/,
/^\/api\/v1\/orgs(\/|$|\?)/,
/^\/api\/v1\/org(\/|$|\?)/,
/^\/api\/v1\/teams(\/|$|\?)/,
/^\/api\/v1\/repos(\/|$|\?)/,
/^\/api\/v1\/repositories(\/|$|\?)/,
/^\/api\/v1\/notifications(\/|$|\?)/,
/^\/api\/v1\/issues(\/|$|\?)/,
/^\/api\/v1\/packages(\/|$|\?)/,
// Instance metadata the UI reads to match the instance's own behaviour rather than guess it —
// default branch name, whether the markdown renderer allows raw HTML, which features are enabled.
/^\/api\/v1\/settings(\/|$|\?)/,
/^\/api\/v1\/topics(\/|$|\?)/,
/^\/api\/v1\/licenses(\/|$|\?)/,
/^\/api\/v1\/gitignore(\/|$|\?)/,
/^\/api\/v1\/label(\/|$|\?)/,
// Rendering. Gitea's own web UI renders a README through these, and matching it matters: the
// instance resolves relative links, #issue references and @mentions against the repo context, which a
// client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown.
/^\/api\/v1\/markdown(\/|$|\?)/,
/^\/api\/v1\/markup(\/|$|\?)/,
];
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 bad = (error: string, status = 400) => Response.json({ error }, { status });
/** Whoever owns the platform configures the instance; everyone else supplies only their own token. */
async function isOwner(userId: number): Promise<boolean> {
const owner = await getOwnerUser();
return !!owner && owner.id === userId;
}
// A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs
// no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have
// not connected yet" from "there is nothing here to connect to", which are different screens.
async function connectionState(userId: number): Promise<Response> {
const connection = await getServiceConnection(userId, 'gitea');
const instanceConfigured = !!(await getServiceInstanceUrl('gitea'));
return Response.json({
configured: !!connection,
connection,
instanceConfigured,
isOwner: await isOwner(userId),
});
}
async function handleConfig(req: Request, userId: number): Promise<Response> {
if (req.method === 'GET') return connectionState(userId);
if (req.method === 'DELETE') {
await deleteServiceConnection(userId, 'gitea');
invalidateGiteaConfig(userId);
return connectionState(userId);
}
if (req.method === 'PUT' || req.method === 'POST') {
const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {};
// A blank token on a save means "keep the stored one", so the URL can be corrected without the
// token being re-pasted. There is no way to read it back out to re-send it.
const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null;
const owner = await isOwner(userId);
// The instance is the owner's; the token is each person's own. Which half of the form you get is
// decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a
// rule: a member who could name the URL would have a per-user SSRF hop behind a settings form.
let base: string;
if (owner) {
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
if (!url) return bad('url is required');
if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://');
base = url;
} else {
// Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere
// and leave them debugging a screen that is quietly talking to a different server.
if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403);
const instance = await getServiceInstanceUrl('gitea');
if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409);
base = instance;
}
const existing = await getServiceConnection(userId, 'gitea');
if (!token && !existing?.hasSecret) return bad('an access token is required the first time');
// Probe with the credentials as they WILL be, so a save either stores something that works or
// fails with the reason. Storing first and discovering later makes every downstream screen fail
// mysteriously instead. For a member this also proves their token is valid against the shared
// instance, which is the only thing that can be wrong on their side.
const current = token ? null : await getGiteaConfig(userId);
const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null });
if (!result.ok) return bad(result.error ?? 'could not reach the instance');
const connection = await saveServiceConnection({
userId,
service: 'gitea',
// Null for anyone but the owner: their row is a credential, and inherits the instance on read.
url: owner ? base : null,
secret: token ?? undefined,
version: result.version,
});
invalidateGiteaConfig(userId);
return Response.json({ connection, user: result.user, version: result.version });
}
return bad('method not allowed', 405);
}
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Release attachments and file uploads through the contents API.
maxRequestBodySize: 64 * 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 });
}
try {
if (url.pathname === '/_config') return await handleConfig(req, userId);
const config = await getGiteaConfig(userId);
if (url.pathname === '/_health') {
if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(config);
const ms = Date.now() - started;
if (!result.ok) {
return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
}
await recordServiceProbe(userId, 'gitea', result.version);
return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms });
}
if (url.pathname.startsWith('/_api/')) {
if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 });
const subpath = url.pathname.slice('/_api'.length) + url.search;
if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403);
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const upstream = await callGitea(config, subpath, {
method: req.method,
headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {},
body: hasBody ? await req.arrayBuffer() : undefined,
});
return new Response(upstream.body, {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
});
}
} catch (err) {
// Path only. A repository body is the owner's private code.
console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err));
return Response.json({ error: 'internal error' }, { status: 500 });
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`);
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: 'gitea',
capabilities: ['gitea'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'gitea:server', port });
console.log(`[gitea] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[gitea] ${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'));
+102
View File
@@ -0,0 +1,102 @@
import { getResolvedServiceCredentials } from 'officerdb';
// Where the Gitea instance is and what it takes to talk to it. The credential lives here and only here —
// the platform forwards to this sidecar and holds nothing.
//
// Gitea authenticates API calls with a personal access token minted from the instance's own
// Settings → Applications → Access Tokens. That is the OWNER'S to create and paste in; there is no way to
// derive one from a platform session, and no reason to want one.
//
// THE HEADER IS `token `, NOT `Bearer `. Gitea's own scheme, confirmed against a live 1.27.0 instance's
// swagger: `AuthorizationHeaderToken` is documented as "API tokens must be prepended with \"token\"
// followed by a space." Recent Gitea also tolerates `Bearer`, but `token` is the documented spelling and
// the one that works on every version we might be pointed at. The query-string forms (`?access_token=` /
// `?token=`) are deprecated for removal and must not be used — they leak the credential into access logs.
export type GiteaConfig = { id: number; base: string; token: string | null };
/** Trailing slash stripped, so `${base}/api/v1/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
const cache = new Map<number, GiteaConfig | null>();
export function invalidateGiteaConfig(userId: number): void {
cache.delete(userId);
}
// One Gitea instance, one token per person. The base comes from whoever owns the platform; the token is
// this caller's own, so what they see is their account — their repos, their notifications, their issues.
//
// getResolvedServiceCredentials does the inheriting: the owner's row carries the URL, everyone else's
// carries only a token and resolves the base from the owner's. It returns null when the pairing is not
// usable — no row for this user, or a row waiting on an instance the owner has not connected — and both
// cases mean the same thing to every caller here: Gitea is not available to you yet.
//
// A member's URL is never read from their own row because it is never written there. That is what stops
// a settings form from becoming a per-user SSRF hop.
export async function getGiteaConfig(userId: number): Promise<GiteaConfig | null> {
if (cache.has(userId)) return cache.get(userId) ?? null;
const row = await getResolvedServiceCredentials(userId, 'gitea');
const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
cache.set(userId, config);
return config;
}
export const authHeaders = (config: GiteaConfig): Record<string, string> =>
config.token ? { Authorization: `token ${config.token}` } : {};
/**
* One call against the instance. Returns the raw Response so callers can stream or inspect status —
* nothing here parses a repository or an issue, because that is the owner's code and this layer has no
* business reading it.
*/
export async function callGitea(config: GiteaConfig, path: string, init?: RequestInit): Promise<Response> {
const headers = { ...authHeaders(config), ...((init?.headers as Record<string, string>) ?? {}) };
return fetch(`${config.base}${path}`, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(20_000) });
}
export type ProbeResult = { ok: boolean; version: string | null; user: string | null; error?: string };
/**
* Is the instance up, and does the stored token actually work?
*
* Both calls carry the token, which is the one place this deliberately departs from the memos sidecar's
* shape. Memos probes `/healthz` unauthenticated first so a bad URL is distinguishable from a bad token.
* Gitea has no such endpoint under a common configuration: with `REQUIRE_SIGNIN_VIEW` enabled — which the
* instance this was built against has — even `/api/v1/version` answers 403 with "Only signed in user is
* allowed to call APIs". An unauthenticated pre-flight would therefore report "unreachable" for a
* perfectly healthy server, which is the most misleading answer available.
*
* So the split is by failure mode instead, which is what the UI actually needs to tell apart:
* throw (DNS, refused, TLS, timeout) → unreachable, the URL is wrong
* 401/403 → reached it fine, the token is wrong
* 200 → both good
* Verified against the live instance: unauthenticated /version → 403, /version with a junk token → 401.
*/
export async function probe(config: GiteaConfig): Promise<ProbeResult> {
if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' };
let version: string | null = null;
try {
const res = await callGitea(config, '/api/v1/version', { signal: AbortSignal.timeout(8000) });
if (res.status === 401 || res.status === 403) {
return { ok: false, version: null, user: null, error: `token rejected (${res.status})` };
}
if (!res.ok) return { ok: false, version: null, user: null, error: `instance returned ${res.status}` };
const body = (await res.json().catch(() => ({}))) as { version?: string };
version = body.version ?? null;
} catch (err) {
return { ok: false, version: null, user: null, error: `unreachable: ${String(err)}` };
}
// `/version` is satisfied by any valid token; `/user` is what proves the token is bound to an actual
// account and yields the identity the connection screen shows back to the owner.
try {
const me = await callGitea(config, '/api/v1/user');
if (!me.ok) return { ok: false, version, user: null, error: `token rejected (${me.status})` };
const body = (await me.json().catch(() => ({}))) as { login?: string; full_name?: string; username?: string };
return { ok: true, version, user: body.login || body.username || body.full_name || null };
} catch (err) {
return { ok: false, version, user: null, error: String(err) };
}
}
+3 -1
View File
@@ -21,7 +21,9 @@ export function invalidateMemosConfig(userId: number): void {
export async function getMemosConfig(userId: number): Promise<MemosConfig | null> {
if (cache.has(userId)) return cache.get(userId) ?? null;
const row = await getServiceCredentials(userId, 'memos');
const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
// `url` is nullable now that some services inherit an instance (see the schema). memos is a single
// daemon and always stores its own, so a null here means a malformed row, not an inheriting one.
const config = row?.url ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
cache.set(userId, config);
return config;
}
+4 -1
View File
@@ -32,7 +32,10 @@ export async function getSlskdConfig(userId: number): Promise<SlskdConfig | null
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
const creds = await getServiceCredentials(userId, 'slskd');
const cfg = creds ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null;
// `url` became nullable when services that inherit one instance landed (see the schema column note).
// slskd fronts a single daemon and always stores its own, so null here is a malformed row, not an
// inheriting one — treat it as unconfigured rather than reaching for somebody else's URL.
const cfg = creds?.url ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null;
cache.set(userId, { cfg, at: Date.now() });
return cfg;
}
+3 -1
View File
@@ -55,7 +55,9 @@ export async function getTransmissionConfig(userId: number): Promise<Transmissio
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
const creds = await getServiceCredentials(userId, 'transmission');
const cfg = creds
// `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),