Files
jellyfin/db/queries.ts
T
pastilhasandClaude Fable 5 05e11f81c9 store the token provisioning mints, so members can open video
Provisioning already created each member's Jellyfin account and signed in as
them to get a token — its own header says the token exists so "the person's
Officer session can already read their library". It was then dropped, because
linkJellyfinAccount had nowhere to put it.

So every non-owner resolved to no config and got a 503 setup form, asking them
to connect a Jellyfin server with a password nobody knows. It reads as a
permissions fault and is not one: the grant, the role and the account list were
all correct.

jellyfin_accounts now carries access_token and device_id, getConfig falls back
to the link row when the caller has no registry of their own, and there is no
path from there to the owner's token — a member resolving to the owner's
credential would read the owner's watch state and every library.

POST /_config/:id/provision repairs accounts linked before the column existed.
It resets the password to mint, which is free on an account Officer created
(random, discarded, known to nobody) and destructive on an adopted one, so
adopted accounts are skipped unless includeAdopted says otherwise.

Applied on alpha: conceiba and guipinto minted and verified against the live
server; fedra and diogo skipped as adopted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:26:11 +00:00

343 lines
13 KiB
TypeScript

import { eq, and, desc, isNotNull } from 'drizzle-orm';
import { db } from 'officerdb/db';
import { jellyfinServers, jellyfinAccounts } from './schema';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
// Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and
// from at-rest ciphertext happens here. See ../crypto.ts and ../schema/jellyfin.ts.
//
// Two return types, and the split is the safety property:
// JellyfinServer — safe to serialize to the browser. Has NO token field at all, not even masked.
// JellyfinCredentials — url + decrypted token + the pinned Jellyfin user, for the sidecar's own calls.
// `serverCols` is what enforces it: a bare `select()` would put the ciphertext column into every list
// response the moment someone forgot to strip it.
export type JellyfinServer = {
id: number;
label: string;
url: string;
jellyfinUserId: string;
jellyfinUsername: string | null;
serverName: string | null;
version: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
};
export type JellyfinCredentials = {
id: number;
label: string;
url: string;
accessToken: string;
jellyfinUserId: string;
deviceId: string;
};
const serverCols = {
id: jellyfinServers.id,
label: jellyfinServers.label,
url: jellyfinServers.url,
jellyfinUserId: jellyfinServers.jellyfinUserId,
jellyfinUsername: jellyfinServers.jellyfinUsername,
serverName: jellyfinServers.serverName,
version: jellyfinServers.version,
isActive: jellyfinServers.isActive,
lastSeenAt: jellyfinServers.lastSeenAt,
createdAt: jellyfinServers.createdAt,
};
const toCredentials = (row: typeof jellyfinServers.$inferSelect): JellyfinCredentials => ({
id: row.id,
label: row.label,
url: row.url,
accessToken: decryptSecret('jellyfin', row.accessToken),
jellyfinUserId: row.jellyfinUserId,
deviceId: row.deviceId,
});
/** Every server the owner has added, active first then newest. Never includes the token. */
export async function listJellyfinServers(userId: number): Promise<JellyfinServer[]> {
return db
.select(serverCols)
.from(jellyfinServers)
.where(eq(jellyfinServers.userId, userId))
.orderBy(desc(jellyfinServers.isActive), desc(jellyfinServers.createdAt));
}
/** The selected server with its token decrypted, or null when none is added. */
export async function getActiveJellyfinCredentials(userId: number): Promise<JellyfinCredentials | null> {
const [row] = await db
.select()
.from(jellyfinServers)
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
return row ? toCredentials(row) : null;
}
/**
* The credentials a NON-OWNER reads the library with: the owner's selected server, and that person's own
* token from their link row.
*
* `id` and `label` are the SERVER's, deliberately — a probe has to be recorded against the row it reached,
* and everything downstream of this treats the result as "the connection in use" without caring whose
* token is inside it. The only fields that differ from the owner's are the three that must: the token, the
* Jellyfin user it belongs to, and the device id its sessions are keyed by.
*
* Two conditions, and both are the reason this returns null rather than a fallback:
*
* - **The server must be the ACTIVE one.** Accounts are provisioned against one instance, so a member
* linked to the server the owner has since switched away from is genuinely not provisioned on the one
* in use. Answering with the other server's URL would hand them a token it does not accept.
* - **The token must exist.** An adopted account has none until somebody supplies its password. Null
* here becomes the 503 the UI already knows how to explain.
*
* There is deliberately no path from here to the OWNER's token. A member resolving to the owner's
* credential would read the owner's watch state and every library, which is the same failure mode
* `getOwnerHomeDir` has on the filesystem side — see `authorize.ts` on confined permissions.
*/
export async function getLinkedJellyfinCredentials(userId: number): Promise<JellyfinCredentials | null> {
const [row] = await db
.select({
id: jellyfinServers.id,
label: jellyfinServers.label,
url: jellyfinServers.url,
accessToken: jellyfinAccounts.accessToken,
jellyfinUserId: jellyfinAccounts.jellyfinUserId,
deviceId: jellyfinAccounts.deviceId,
})
.from(jellyfinAccounts)
.innerJoin(jellyfinServers, eq(jellyfinAccounts.serverId, jellyfinServers.id))
.where(
and(
eq(jellyfinAccounts.userId, userId),
eq(jellyfinServers.isActive, true),
isNotNull(jellyfinAccounts.accessToken),
),
);
// The isNotNull above already excludes it; this narrows the type and keeps the guarantee local.
if (!row?.accessToken) return null;
return {
id: row.id,
label: row.label,
url: row.url,
accessToken: decryptSecret('jellyfin', row.accessToken),
jellyfinUserId: row.jellyfinUserId,
deviceId: row.deviceId ?? `officer-${userId}`,
};
}
/** One server's credentials by id — for probing a specific server rather than the active one. */
export async function getJellyfinCredentials(userId: number, id: number): Promise<JellyfinCredentials | null> {
const [row] = await db
.select()
.from(jellyfinServers)
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)));
return row ? toCredentials(row) : null;
}
type CreateJellyfinServerParams = {
userId: number;
label: string;
url: string;
accessToken: string;
jellyfinUserId: string;
jellyfinUsername: string | null;
deviceId: string;
serverName: string | null;
version: string | null;
/** Select it. True for the first server, so the UI is never left with servers added but none chosen. */
activate: boolean;
};
/** Add a server. The token is encrypted before write; the returned row carries no token. */
export async function createJellyfinServer(params: CreateJellyfinServerParams): Promise<JellyfinServer> {
const { userId, activate, accessToken, ...rest } = params;
return db.transaction(async (tx) => {
if (activate) {
await tx
.update(jellyfinServers)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
}
const [row] = await tx
.insert(jellyfinServers)
.values({
userId,
...rest,
accessToken: encryptSecret('jellyfin', accessToken),
isActive: activate,
lastSeenAt: rest.version ? new Date() : null,
})
.returning(serverCols);
return row!;
});
}
type UpdateJellyfinServerParams = {
label?: string;
url?: string;
accessToken?: string;
jellyfinUserId?: string;
jellyfinUsername?: string | null;
serverName?: string | null;
version?: string | null;
};
/** Edit a server in place. Omitting `accessToken` keeps the stored one — re-authenticating replaces it. */
export async function updateJellyfinServer(
userId: number,
id: number,
params: UpdateJellyfinServerParams,
): Promise<JellyfinServer | null> {
const { accessToken, ...rest } = params;
const [row] = await db
.update(jellyfinServers)
.set({
...rest,
...(accessToken ? { accessToken: encryptSecret('jellyfin', accessToken) } : {}),
updatedAt: new Date(),
})
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
.returning(serverCols);
return row ?? null;
}
/** Switch the selected server. Clearing the others and setting this one is one transaction, never two. */
export async function setActiveJellyfinServer(userId: number, id: number): Promise<JellyfinServer | null> {
return db.transaction(async (tx) => {
await tx
.update(jellyfinServers)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
const [row] = await tx
.update(jellyfinServers)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
.returning(serverCols);
return row ?? null;
});
}
/**
* Remove a server. If it was the selected one, the newest remaining server takes its place — leaving the
* owner with servers registered but none active would show the setup form over a working registry.
*/
export async function deleteJellyfinServer(userId: number, id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [gone] = await tx
.delete(jellyfinServers)
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
.returning({ id: jellyfinServers.id, wasActive: jellyfinServers.isActive });
if (!gone) return false;
if (gone.wasActive) {
const [next] = await tx
.select({ id: jellyfinServers.id })
.from(jellyfinServers)
.where(eq(jellyfinServers.userId, userId))
.orderBy(desc(jellyfinServers.createdAt))
.limit(1);
if (next) {
await tx
.update(jellyfinServers)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(jellyfinServers.id, next.id));
}
}
return true;
});
}
/** Stamp a successful probe: what version answered, and when. Failures deliberately leave the row alone. */
export async function recordJellyfinProbe(userId: number, id: number, version: string | null): Promise<void> {
await db
.update(jellyfinServers)
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)));
}
// ── The Officer ↔ Jellyfin account mapping ──
/**
* Links already recorded for one server, for the reconcile to compare against.
*
* `hasToken` rather than the token itself: callers need to know whether this person can reach their
* library, and none of them needs the credential to answer that. The ciphertext does not leave this file.
*/
export async function listJellyfinAccounts(serverId: number): Promise<
{ userId: number; jellyfinUserId: string; jellyfinUsername: string; adopted: boolean; hasToken: boolean }[]
> {
const rows = await db
.select({
userId: jellyfinAccounts.userId,
jellyfinUserId: jellyfinAccounts.jellyfinUserId,
jellyfinUsername: jellyfinAccounts.jellyfinUsername,
adopted: jellyfinAccounts.adopted,
accessToken: jellyfinAccounts.accessToken,
})
.from(jellyfinAccounts)
.where(eq(jellyfinAccounts.serverId, serverId));
return rows.map(({ accessToken, ...rest }) => ({ ...rest, hasToken: !!accessToken }));
}
/**
* Store the token minted for one person, encrypted.
*
* Separate from `linkJellyfinAccount` rather than a parameter on it, because the two answer different
* questions and one of them is re-asked. Linking is `onConflictDoNothing` — a second reconcile finding the
* same pairing must stay silent — but a token is exactly the thing you re-run provisioning to REPLACE,
* after it was revoked from Jellyfin's Devices screen or lost with a server move. An insert that does
* nothing on conflict cannot express that.
*/
export async function setJellyfinAccountToken(params: {
userId: number;
serverId: number;
accessToken: string;
deviceId: string;
}): Promise<void> {
await db
.update(jellyfinAccounts)
.set({ accessToken: encryptSecret('jellyfin', params.accessToken), deviceId: params.deviceId })
.where(and(eq(jellyfinAccounts.userId, params.userId), eq(jellyfinAccounts.serverId, params.serverId)));
}
/**
* Record a pairing.
*
* `onConflictDoNothing` on BOTH unique indexes, so a re-run is silent rather than an error — a second
* reconcile finding the same thing already true is the normal case, not a fault.
*
* `adopted` is the load-bearing field: false means Officer created this account and may remove it, true
* means it existed first and was merely linked. Deleting an adopted account would destroy someone's watch
* history because their Officer login went away.
*/
export async function linkJellyfinAccount(params: {
userId: number;
serverId: number;
jellyfinUserId: string;
jellyfinUsername: string;
adopted: boolean;
}): Promise<void> {
await db
.insert(jellyfinAccounts)
.values({
userId: params.userId,
serverId: params.serverId,
jellyfinUserId: params.jellyfinUserId,
jellyfinUsername: params.jellyfinUsername,
// Mirrors Jellyfin's own `NormalizedUsername`, so matching is a lookup rather than a convention
// every call site has to remember.
normalizedUsername: params.jellyfinUsername.trim().toUpperCase(),
adopted: params.adopted,
})
.onConflictDoNothing();
}
/** Drop a pairing. Removes Officer's RECORD of the account, never the account itself. */
export async function unlinkJellyfinAccount(userId: number, serverId: number): Promise<void> {
await db
.delete(jellyfinAccounts)
.where(and(eq(jellyfinAccounts.userId, userId), eq(jellyfinAccounts.serverId, serverId)));
}