Files
jellyfin/db/queries.ts
T
pastilhas 470d4f83c3 user provisioning, phase one: reconcile against the real server
the platform pushes the account list (users:list, declared by userProvisioning
in the manifest); this decides what to do about each one and does it.

DECIDING IS SEPARATE FROM DOING. reconcile.ts is pure and has no idea what
jellyfin is — which is why it can be tested against the real populations on
alpha without creating anything. nine tests, built on the actual overlap:
diogo and fedra already have jellyfin accounts, conceiba and guipinto do not,
rezio and peter exist only on jellyfin.

that test found a real bug before it shipped. officer's `username` was unique
CASE-SENSITIVELY, so `diogo` and `DIOGO` could both exist here while colliding
on jellyfin's uppercased NormalizedUsername index — and the first version
handed both the same jellyfin account, because it only checked links that
already existed rather than ones claimed during the pass. the platform side is
now case-insensitive too, so the collision cannot be created in the first
place; the guard stays because the constraint and the guard protect different
moments.

three outcomes, and the third is the point: LINK an account that already
exists, CREATE one that does not, LEAVE ALONE a jellyfin account with no
officer user. never delete. an absence in officer is indistinguishable from a
list that has not been told about someone yet, and a wrong deletion costs
somebody their watch history.

`adopted` carries that distinction into the mapping table. false means officer
made the account and may remove it; true means it existed first and was merely
linked.

NOBODY IS EVER TOLD THEIR PASSWORD. jellyfin has no must-change flag — verified
against its openapi, UserPolicy has 44 properties and none of them is one — so
a handed-over password stays valid forever. instead the generated one is used
once, immediately, to mint that person's own access token, and discarded. the
account exists and their officer session can already read their library.

which also makes rule 4 unskippable: the sign-in that verifies the account is
the same call that obtains the token, so there is no version of this that
"verifies later".

one failure does not stop the rest. a partially provisioned server is fixed by
running again; a run that aborts on the first error leaves everyone after them
unprovisioned and no record of who.
2026-08-20 20:14:04 +00:00

262 lines
9.3 KiB
TypeScript

import { eq, and, desc } 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;
}
/** 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. */
export async function listJellyfinAccounts(serverId: number): Promise<
{ userId: number; jellyfinUserId: string; jellyfinUsername: string; adopted: boolean }[]
> {
const rows = await db
.select({
userId: jellyfinAccounts.userId,
jellyfinUserId: jellyfinAccounts.jellyfinUserId,
jellyfinUsername: jellyfinAccounts.jellyfinUsername,
adopted: jellyfinAccounts.adopted,
})
.from(jellyfinAccounts)
.where(eq(jellyfinAccounts.serverId, serverId));
return rows;
}
/**
* 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)));
}