Files
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

245 lines
11 KiB
TypeScript

// Jellyfin upstream config + the one function that talks to it.
//
// All knowledge of the Jellyfin instance — its URL, its access token, the Jellyfin user the token belongs to
// and the DeviceId sessions are keyed by — lives here, mirroring officer-invoiceshelf: the platform API is a
// thin auth+forward proxy and holds NO Jellyfin credentials.
//
// The server is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `jellyfin_servers` (see
// databases/officer_db/src/jellyfin/queries.ts). Nothing in this file reads process.env — Bun auto-loads
// `.env` into every process started in the platform directory, so a token there would also be sitting in
// `officer`'s own environment: a credential held by the one process that has no code to use it.
//
// Three things about Jellyfin's API are load-bearing:
//
// 1. Auth is ONE header — `Authorization: MediaBrowser Token="…", Client=…, Device=…, DeviceId=…,
// Version=…`. The older `X-Emby-Token` still works in 10.11 but carries no client identity, and
// playback reporting correlates sessions by DeviceId, so the full header is what we send.
// 2. The DeviceId must be STABLE per server. A fresh one per request fills Jellyfin's dashboard with
// hundreds of "devices" and detaches each progress report from the session that started playback.
// 3. Almost every per-user route needs the user id in the PATH (`/Users/{id}/Views`), and it is not
// derivable from the token without a round trip — which is why it is pinned on the row at sign-in.
import { getActiveJellyfinCredentials, getLinkedJellyfinCredentials } from '../db';
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
export type UpstreamTarget = {
base: string;
token: string;
jellyfinUserId: string;
deviceId: string;
};
/**
* A stored server, which is where every real call goes.
*
* `id` and `label` ride along because the owner has several instances registered (this machine runs four)
* and only one selected: a probe has to be recorded against the row it actually reached, and a log line
* saying which instance answered is the difference between "Jellyfin is broken" and "you are looking at
* the albums server".
*/
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
/** Trailing slashes off, so `${base}/Items/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
// A library grid is a burst of requests and each one needs the token, so the row is cached rather than
// re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql,
// which then takes effect within a minute instead of needing a restart.
const TTL_MS = 60_000;
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
/**
* What THIS account reads Jellyfin with, or null when there is nothing — the sidecar then answers 503, and
* the UI turns that into the setup form rather than a screen of empty shelves.
*
* Two sources, in this order:
*
* 1. **A server this account registered itself.** The owner's normal case, and also a member who
* deliberately connected a Jellyfin of their own. Checked first so that a person who went to the
* trouble of adding one is not overridden by an inherited link.
* 2. **A provisioned link on the owner's active server**, carrying that person's OWN token. This is how
* a member reaches the library without ever holding a credential or seeing a setup form.
*
* The second source is what makes `userProvisioning` mean anything. Before 2026-08-21 it did not exist,
* so every member fell through to null and got a 503 telling them to connect a Jellyfin server — advice
* they could not act on, since the account Officer made for them has a password nobody knows.
*/
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
const hit = cache.get(userId);
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
const creds = (await getActiveJellyfinCredentials(userId)) ?? (await getLinkedJellyfinCredentials(userId));
const cfg = creds
? {
id: creds.id,
label: creds.label,
base: normalizeBase(creds.url),
token: creds.accessToken,
jellyfinUserId: creds.jellyfinUserId,
deviceId: creds.deviceId,
}
: null;
cache.set(userId, { cfg, at: Date.now() });
return cfg;
}
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
export function invalidateConfig(userId: number): void {
cache.delete(userId);
}
/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */
export class UpstreamError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
const CLIENT = 'Officer';
const DEVICE = 'Officer Web';
const CLIENT_VERSION = '1.0.0';
/**
* The `Authorization` header Jellyfin expects, with or without a token.
*
* Signing in has to send the same header MINUS the token: Jellyfin refuses AuthenticateByName outright
* without a client identity, which is the single most common reason a hand-rolled login gets a 400.
*/
export function authHeader(deviceId: string, token?: string): string {
const parts = [`Client="${CLIENT}"`, `Device="${DEVICE}"`, `DeviceId="${deviceId}"`, `Version="${CLIENT_VERSION}"`];
if (token) parts.push(`Token="${token}"`);
return `MediaBrowser ${parts.join(', ')}`;
}
export type AuthResult = { token: string; userId: string; username: string | null; serverName: string | null };
/**
* Trade a username and password for an access token.
*
* The password is used for this one call and never stored — only the token it returns is persisted, the
* same trade officer-invoiceshelf makes. Jellyfin CAN issue API keys from its dashboard, but those are
* server-wide admin keys with no user identity, and every per-user route (resume points, watched state,
* favourites) needs a user. So we sign in as the owner rather than key the server.
*
* Each sign-in mints a NEW token; the previous one is deliberately left alone, because Officer cannot know
* whether the owner also uses it somewhere else. Old ones are revoked from Jellyfin's own Devices screen.
*/
export async function authenticate(
base: string,
deviceId: string,
username: string,
password: string,
): Promise<AuthResult> {
const res = await fetch(`${base}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
Authorization: authHeader(deviceId),
'Content-Type': 'application/json',
Accept: 'application/json',
},
// `Pw` is the plaintext field. `Password` is the legacy SHA1 one and is ignored by modern servers.
body: JSON.stringify({ Username: username, Pw: password }),
redirect: 'manual',
});
if (!res.ok) {
const detail = res.status === 401 ? 'wrong username or password' : `sign-in failed with ${res.status}`;
throw new UpstreamError(detail, res.status);
}
const payload = (await res.json()) as {
AccessToken?: unknown;
User?: { Id?: unknown; Name?: unknown };
ServerId?: unknown;
};
if (typeof payload.AccessToken !== 'string' || !payload.AccessToken) {
throw new UpstreamError('sign-in returned no access token', res.status);
}
if (typeof payload.User?.Id !== 'string' || !payload.User.Id) {
throw new UpstreamError('sign-in returned no user id', res.status);
}
return {
token: payload.AccessToken,
userId: payload.User.Id,
username: typeof payload.User.Name === 'string' ? payload.User.Name : null,
serverName: null,
};
}
export type PublicInfo = { serverName: string | null; version: string | null };
/**
* The unauthenticated identity endpoint, used before any credential exists so the setup form can confirm
* the URL points at a Jellyfin at all — and name it — before asking for a password.
*/
export async function publicInfo(base: string): Promise<PublicInfo> {
const res = await fetch(`${base}/System/Info/Public`, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new UpstreamError(`not a reachable Jellyfin (${res.status})`, res.status);
const payload = (await res.json()) as { ServerName?: unknown; Version?: unknown };
return {
serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
version: typeof payload.Version === 'string' ? payload.Version : null,
};
}
type CallOptions = {
/** Absolute path on the Jellyfin host, e.g. `/Items`. */
path: string;
method?: string;
/** Raw search string including the leading `?`, or empty. */
query?: string;
body?: BodyInit | null;
contentType?: string | null;
/** Defaults to `application/json`; image and video routes ask for the bytes instead. */
accept?: string;
/** Forwarded verbatim for seeking — Jellyfin answers 206 for both direct play and HLS segments. */
range?: string | null;
};
/** The single door to Jellyfin. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = {
Authorization: authHeader(cfg.deviceId, cfg.token),
Accept: opts.accept ?? 'application/json',
};
if (opts.contentType) headers['Content-Type'] = opts.contentType;
if (opts.range) headers.Range = opts.range;
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body ?? undefined,
redirect: 'manual',
});
if (res.status === 401) {
console.warn(`[jellyfin] ${opts.method ?? 'GET'} ${opts.path} → 401 (token rejected)`);
}
return res;
}
export type ProbeResult = { ok: boolean; version: string | null; serverName: string | null; error?: string };
/**
* Is the stored token still good?
*
* `/System/Info` (no `/Public`) is the right probe precisely because it is authenticated: the public one
* answers 200 to a revoked token, so a probe against it would call a dead connection healthy.
*/
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
try {
const res = await callUpstream(cfg, { path: '/System/Info' });
if (res.status === 401) return { ok: false, version: null, serverName: null, error: 'access token rejected' };
if (!res.ok) return { ok: false, version: null, serverName: null, error: `server answered ${res.status}` };
const payload = (await res.json()) as { Version?: unknown; ServerName?: unknown };
return {
ok: true,
version: typeof payload.Version === 'string' ? payload.Version : null,
serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
};
} catch (err) {
return { ok: false, version: null, serverName: null, error: err instanceof Error ? err.message : 'unreachable' };
}
}