diff --git a/db/index.ts b/db/index.ts index ab69de6..5838019 100644 --- a/db/index.ts +++ b/db/index.ts @@ -1,6 +1,7 @@ export { listJellyfinServers, getActiveJellyfinCredentials, + getLinkedJellyfinCredentials, getJellyfinCredentials, createJellyfinServer, updateJellyfinServer, @@ -9,5 +10,10 @@ export { recordJellyfinProbe, } from './queries'; -export { listJellyfinAccounts, linkJellyfinAccount, unlinkJellyfinAccount } from './queries'; +export { + listJellyfinAccounts, + linkJellyfinAccount, + setJellyfinAccountToken, + unlinkJellyfinAccount, +} from './queries'; export type { JellyfinServer, JellyfinCredentials } from './queries'; diff --git a/db/queries.ts b/db/queries.ts index 8d4a149..45d0c8a 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -1,4 +1,4 @@ -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, isNotNull } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { jellyfinServers, jellyfinAccounts } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; @@ -74,6 +74,60 @@ export async function getActiveJellyfinCredentials(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 @@ -205,9 +259,14 @@ export async function recordJellyfinProbe(userId: number, id: number, version: s // ── The Officer ↔ Jellyfin account mapping ── -/** Links already recorded for one server, for the reconcile to compare against. */ +/** + * 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 }[] + { userId: number; jellyfinUserId: string; jellyfinUsername: string; adopted: boolean; hasToken: boolean }[] > { const rows = await db .select({ @@ -215,10 +274,32 @@ export async function listJellyfinAccounts(serverId: number): Promise< jellyfinUserId: jellyfinAccounts.jellyfinUserId, jellyfinUsername: jellyfinAccounts.jellyfinUsername, adopted: jellyfinAccounts.adopted, + accessToken: jellyfinAccounts.accessToken, }) .from(jellyfinAccounts) .where(eq(jellyfinAccounts.serverId, serverId)); - return rows; + 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))); } /** diff --git a/db/schema.ts b/db/schema.ts index 2f1fd4b..5848886 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -105,6 +105,29 @@ export const jellyfinAccounts = pgTable( normalizedUsername: text('normalized_username').notNull(), /** False = Officer created it and may remove it. True = it pre-existed and was linked. */ adopted: boolean('adopted').notNull().default(false), + /** + * That person's OWN Jellyfin access token, encrypted — the credential their Officer session reads the + * library with. + * + * This column is the whole point of the per-user design and it was missing until 2026-08-21. + * `provision.ts` already minted exactly this token, said in its own header that it exists so "the + * person's Officer session can already read their library", and then dropped it on the floor because + * `linkJellyfinAccount` had nowhere to put it. Every non-owner therefore resolved to no config and got + * a 503 setup form, while the owner worked — which reads as a permissions fault and is not one. + * + * NULLABLE, and the null case is normal rather than an error: a token can only be obtained by signing + * in, so an ADOPTED account has none until somebody supplies its password. An Officer-created account + * always has one, because provisioning knows the password it just set. + */ + accessToken: text('access_token'), // encrypted + /** + * The DeviceId this person's sessions are keyed by, `officer-`. + * + * Written with the token and meaningless without it. Distinct from the owner's device id on purpose — + * Jellyfin lists them separately under Devices, so revoking one person's access does not sign + * everybody else out. + */ + deviceId: text('device_id'), linkedAt: timestamp('linked_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ diff --git a/sidecar/config.ts b/sidecar/config.ts index 64dce9f..3e0ec23 100644 --- a/sidecar/config.ts +++ b/sidecar/config.ts @@ -9,7 +9,7 @@ import { updateJellyfinServer, } from '../db'; import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream'; -import { runProvisioning } from './provision-run'; +import { backfillAccountTokens, runProvisioning } from './provision-run'; import { buildAccountView } from './account-view'; // `/_config` — the Jellyfin server registry, driven from the app. @@ -136,7 +136,14 @@ async function addServer(req: Request, userId: number): Promise { jellyfinUserId: auth.userId, deviceId, }); - return Response.json({ server, provisioning }); + // And the tokens for anyone this server already knew about. Reconnecting an instance that has been + // registered before is the ordinary case for a moved server or a revoked credential, and its accounts + // are all "already linked" by then — so provisioning alone would report success and change nothing. + const tokens = await backfillAccountTokens({ + cfg: { base: url, token: auth.token, jellyfinUserId: auth.userId, deviceId }, + serverId: server.id, + }); + return Response.json({ server, provisioning, tokens }); } catch (err) { if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`); throw err; @@ -211,6 +218,43 @@ async function editServer(req: Request, userId: number, id: number): Promise { + const creds = await getJellyfinCredentials(userId, id); + if (!creds) return bad('no such server', 404); + + const body = ((await req.json().catch(() => null)) as { includeAdopted?: unknown } | null) ?? {}; + // `=== true` rather than truthy: this decides whether somebody else's Jellyfin password gets reset, and + // a stray "false" string must not be the thing that turns it on. + const includeAdopted = body.includeAdopted === true; + + const base = normalizeBase(creds.url); + const cfg = { base, token: creds.accessToken, jellyfinUserId: creds.jellyfinUserId, deviceId: creds.deviceId }; + + const provisioning = await runProvisioning({ + userId, + serverId: id, + base, + token: creds.accessToken, + jellyfinUserId: creds.jellyfinUserId, + deviceId: creds.deviceId, + }); + const tokens = await backfillAccountTokens({ cfg, serverId: id, includeAdopted }); + + return Response.json({ provisioning, tokens }); +} + /** Check one stored server without switching to it — what the "test" button on each row calls. */ async function testServer(userId: number, id: number): Promise { const creds = await getJellyfinCredentials(userId, id); @@ -246,6 +290,11 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s return serverList(userId); } + if (action === 'provision') { + if (req.method !== 'POST') return bad('method not allowed', 405); + return reprovision(req, userId, id); + } + // Who exists on each side, and which pairs are the same person. Read-only and computed on demand — // nothing is cached, because the interesting case is somebody adding or removing an account in // Jellyfin's own UI and wondering why Officer disagrees. diff --git a/sidecar/index.ts b/sidecar/index.ts index c39a904..23f6ea9 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -23,6 +23,9 @@ import { setProvisionableUsers } from './user-list'; // for an access token and never stored // PATCH /_config/:id same fields, all optional; no password keeps the stored token // POST /_config/:id/activate switch to that server +// POST /_config/:id/provision re-run account provisioning and mint any missing member tokens. +// `{ includeAdopted }` opts into resetting the password of an +// account that pre-dated Officer — off unless asked for // GET /_config/:id/test probe one server without switching to it // DEL /_config/:id remove it; the newest survivor is promoted if it was active // diff --git a/sidecar/provision-run.ts b/sidecar/provision-run.ts index 1d58b31..2a94417 100644 --- a/sidecar/provision-run.ts +++ b/sidecar/provision-run.ts @@ -1,6 +1,7 @@ -import { listJellyfinAccounts, linkJellyfinAccount } from '../db'; -import { provision, type ProvisionOutcome } from './provision'; +import { listJellyfinAccounts, linkJellyfinAccount, setJellyfinAccountToken } from '../db'; +import { mintTokenForLinked, provision, type ProvisionOutcome } from './provision'; import { getProvisionableUsers, hasProvisionableUsers } from './user-list'; +import { invalidateConfig, type UpstreamTarget } from './upstream'; // The one place that turns "an admin just connected" into accounts on the server. // @@ -68,6 +69,16 @@ export async function runProvisioning(params: { jellyfinUsername: made.username, adopted: false, }); + // The token, which is the half that makes the account reachable. Written as a second statement + // because `linkJellyfinAccount` does nothing on conflict — see `setJellyfinAccountToken`. Without + // this the person has a Jellyfin account, a link row, and no way to open either. + await setJellyfinAccountToken({ + userId: made.userId, + serverId, + accessToken: made.token, + deviceId: made.deviceId, + }); + invalidateConfig(made.userId); } for (const line of outcome.log) console.log(`[jellyfin:provision] ${line}`); @@ -87,3 +98,70 @@ export async function runProvisioning(params: { return { ran: false, reason }; } } + +export type BackfillReport = { + minted: string[]; + /** Already had a token. Reported rather than silent, so a re-run visibly does nothing. */ + alreadyLinked: string[]; + /** Adopted accounts left alone, and why — the owner has to choose before these can be touched. */ + skipped: { username: string; reason: string }[]; + failed: { username: string; error: string }[]; +}; + +/** + * Give a token to every linked account that has none. + * + * This is the repair for accounts provisioned BEFORE `jellyfin_accounts.access_token` existed. Those rows + * record a real Jellyfin account and a real pairing — the only missing piece is the credential, which was + * minted at the time and discarded. Nothing here creates or deletes an account; it only fills the gap. + * + * Idempotent, and cheap when there is nothing to do: an account that already has a token is skipped + * without a single upstream call, so this is safe to run on every re-provision. + * + * `includeAdopted` defaults to FALSE and that default is the safety property. Minting for an adopted + * account means resetting a password its owner knows and may use to sign into Jellyfin directly — see + * `mintTokenForLinked`. Turning it on is a decision about somebody else's account, so it is never the + * default and never inferred. + */ +export async function backfillAccountTokens(params: { + cfg: UpstreamTarget; + serverId: number; + includeAdopted?: boolean; +}): Promise { + const { cfg, serverId, includeAdopted = false } = params; + const report: BackfillReport = { minted: [], alreadyLinked: [], skipped: [], failed: [] }; + + for (const account of await listJellyfinAccounts(serverId)) { + if (account.hasToken) { + report.alreadyLinked.push(account.jellyfinUsername); + continue; + } + if (account.adopted && !includeAdopted) { + report.skipped.push({ + username: account.jellyfinUsername, + reason: 'this account existed before Officer, so its password is theirs — resetting it would lock them out of Jellyfin itself', + }); + continue; + } + + try { + const { token, deviceId } = await mintTokenForLinked(cfg, { + officerUserId: account.userId, + jellyfinUserId: account.jellyfinUserId, + username: account.jellyfinUsername, + }); + await setJellyfinAccountToken({ userId: account.userId, serverId, accessToken: token, deviceId }); + // Their cached config says "nothing configured" and would hold that for up to a minute. They are + // most likely staring at the setup form right now, so drop it immediately. + invalidateConfig(account.userId); + report.minted.push(account.jellyfinUsername); + console.log(`[jellyfin:provision] minted a token for ${account.jellyfinUsername}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + report.failed.push({ username: account.jellyfinUsername, error: message }); + console.error(`[jellyfin:provision] ${account.jellyfinUsername}: ${message}`); + } + } + + return report; +} diff --git a/sidecar/provision.ts b/sidecar/provision.ts index b1775a2..15e0f1d 100644 --- a/sidecar/provision.ts +++ b/sidecar/provision.ts @@ -58,10 +58,19 @@ async function grantAllLibraries( /** Long enough that it does not matter that it is never rotated, since nobody ever knows it. */ const generatePassword = (): string => randomBytes(32).toString('base64url'); +/** + * The DeviceId one person's Officer sessions are keyed by. + * + * Derived from the Officer user id rather than random, so re-minting a token for somebody reuses their + * entry in Jellyfin's Devices screen instead of adding a second ghost beside it — the same reasoning + * `editServer` applies when the owner signs in again. + */ +export const memberDeviceId = (officerUserId: number): string => `officer-${officerUserId}`; + export type ProvisionOutcome = { /** Officer user id → what happened. */ linked: { userId: number; jellyfinUserId: string; username: string }[]; - created: { userId: number; jellyfinUserId: string; username: string; token: string }[]; + created: { userId: number; jellyfinUserId: string; username: string; token: string; deviceId: string }[]; /** Left alone, reported so the owner can see accounts Officer does not manage. */ untouched: JellyfinUser[]; failed: { username: string; error: string }[]; @@ -87,7 +96,7 @@ export async function listJellyfinUsers(cfg: UpstreamTarget): Promise { +): Promise<{ jellyfinUserId: string; token: string; deviceId: string }> { const password = generatePassword(); const res = await callUpstream(cfg, { @@ -120,8 +129,56 @@ async function createOne( // Rule 4, and the token in one call. A per-user device id keeps this session distinct from the owner's // in Jellyfin's Devices screen, so revoking one does not sign out the other. - const auth = await authenticate(cfg.base, `officer-${user.id}`, user.username!, password); - return { jellyfinUserId: created.Id, token: auth.token }; + const deviceId = memberDeviceId(user.id); + const auth = await authenticate(cfg.base, deviceId, user.username!, password); + return { jellyfinUserId: created.Id, token: auth.token, deviceId }; +} + +/** + * Mint a token for an account that is ALREADY linked — the repair path, and the only way to give a member + * access to an account whose password nobody holds. + * + * ── Why this has to reset the password ── + * + * Jellyfin issues a token exactly one way: `AuthenticateByName`, with the password. There is no admin + * impersonation endpoint, and its API keys are server-wide and identity-less, so they cannot stand in for + * a user (the same reason `ConnectionSection` offers no "paste an API key" field). An administrator CAN + * set another user's password without knowing the old one — `POST /Users/{id}/Password` with an empty + * `CurrentPw`, verified against 10.11 — so the sequence is: set a fresh random password, spend it once on + * a sign-in, discard it. Exactly what `createOne` does, minus the create. + * + * ── Which accounts this is safe on, and which it is not ── + * + * Safe on an account Officer created (`adopted: false`). Its password was random and discarded at + * creation, so nobody knows it, nothing stores it, and replacing it takes nothing away from anyone. + * + * NOT safe on an adopted one. That account existed before Officer and its password is somebody's, known + * to them and possibly used to sign into Jellyfin directly. Resetting it would lock that person out of + * their own account to fix a problem they do not have. The caller decides — see `backfillAccountTokens`, + * which refuses adopted accounts unless told otherwise in as many words. + */ +export async function mintTokenForLinked( + cfg: UpstreamTarget, + account: { officerUserId: number; jellyfinUserId: string; username: string }, +): Promise<{ token: string; deviceId: string }> { + const password = generatePassword(); + + const res = await callUpstream(cfg, { + method: 'POST', + path: `/Users/${account.jellyfinUserId}/Password`, + contentType: 'application/json', + // `ResetPassword: false` — that flag CLEARS the password rather than setting it, which would leave the + // account reachable by anyone who can see the login screen. + body: JSON.stringify({ CurrentPw: '', NewPw: password, ResetPassword: false }), + }); + + if (!res.ok) { + throw new Error(`POST /Users/${account.jellyfinUserId}/Password → ${res.status}: ${(await res.text()).slice(0, 200)}`); + } + + const deviceId = memberDeviceId(account.officerUserId); + const auth = await authenticate(cfg.base, deviceId, account.username, password); + return { token: auth.token, deviceId }; } /** @@ -161,8 +218,8 @@ export async function provision(params: { for (const user of plan.create) { try { - const { jellyfinUserId, token } = await createOne(cfg, user); - outcome.created.push({ userId: user.id, jellyfinUserId, username: user.username!, token }); + const { jellyfinUserId, token, deviceId } = await createOne(cfg, user); + outcome.created.push({ userId: user.id, jellyfinUserId, username: user.username!, token, deviceId }); } catch (error) { // One person failing must not stop the rest. A partially provisioned server is recoverable by // running again; a run that aborts on the first error leaves everyone after them unprovisioned and diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts index 71b2f24..efeba01 100644 --- a/sidecar/upstream.ts +++ b/sidecar/upstream.ts @@ -19,7 +19,7 @@ // 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 } from '../db'; +import { getActiveJellyfinCredentials, getLinkedJellyfinCredentials } from '../db'; /** Everything needed to make one call. A candidate being validated has this and nothing else yet. */ export type UpstreamTarget = { @@ -49,14 +49,26 @@ const TTL_MS = 60_000; const cache = new Map(); /** - * The owner's SELECTED server, or null when /jellyfin has none yet — the sidecar then answers 503, and the - * UI turns that into the setup form rather than a screen of empty shelves. + * 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 { const hit = cache.get(userId); if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; - const creds = await getActiveJellyfinCredentials(userId); + const creds = (await getActiveJellyfinCredentials(userId)) ?? (await getLinkedJellyfinCredentials(userId)); const cfg = creds ? { id: creds.id,