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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { await db .delete(jellyfinAccounts) .where(and(eq(jellyfinAccounts.userId, userId), eq(jellyfinAccounts.serverId, serverId))); }