import { randomBytes } from 'node:crypto'; import type { ProvisionableUser } from 'officerdb'; import { authenticate, callUpstream, type UpstreamTarget } from './upstream'; import { planReconcile, describePlan, normalize, type JellyfinUser, type ReconcilePlan } from './reconcile'; // Carrying out the plan. `reconcile.ts` decides; this does, and does nothing else. // // ── Nobody is ever told their Jellyfin password ── // // The plan for this deployment is that Jellyfin's own web UI is never exposed: people reach their media // through Officer, and Officer holds the credential. So the generated password is not a handover value — // it is used once, immediately, to mint that person's own access token, and then discarded. // // That is what makes `USER_PROVISIONING.md` rule 3 survivable here. Jellyfin has no must-change-password // flag (verified against its OpenAPI: `UserPolicy` has 44 properties and none of them is one), so a // handed-over password would stay valid indefinitely. A password nobody ever learns cannot. // // It also means provisioning produces something immediately useful rather than a credential in an email: // the account exists AND the person's Officer session can already read their library. // // ── Verify by using it, not by reading the 200 ── // // Rule 4. `POST /Users/New` returning a user object means a row was written, not that the account works — // and here the sign-in is not merely a check, it is how the token is obtained. The verification and the // useful work are the same call, which is the only reason it cannot be skipped when someone is in a hurry. /** * Turn on access to every library, preserving everything else about the policy. * * Not fatal if it fails: the account exists and can sign in, and library access is fixable from Jellyfin's * own UI. Failing the whole creation here would leave an orphan account upstream with no mapping row, * which is a worse state than a user who cannot see films yet. */ async function grantAllLibraries( cfg: UpstreamTarget, jellyfinUserId: string, policy: Record | undefined, ): Promise { if (!policy) { console.warn(`[jellyfin:provision] ${jellyfinUserId}: no Policy on the created user — library access not set`); return; } const res = await callUpstream(cfg, { method: 'POST', path: `/Users/${jellyfinUserId}/Policy`, contentType: 'application/json', // `EnabledFolders` is left as Jellyfin returned it. With `EnableAllFolders` true it is ignored, and // clearing it would lose the per-folder list if the owner ever turns the blanket flag back off. body: JSON.stringify({ ...policy, EnableAllFolders: true }), }); if (!res.ok) { console.error( `[jellyfin:provision] ${jellyfinUserId}: POST Policy → ${res.status}: ${(await res.text()).slice(0, 200)}`, ); } } /** 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; deviceId: string }[]; /** Left alone, reported so the owner can see accounts Officer does not manage. */ untouched: JellyfinUser[]; failed: { username: string; error: string }[]; /** One line per decision, for the install log. */ log: string[]; }; /** Everything Jellyfin knows about, as `GET /Users` reports it. */ export async function listJellyfinUsers(cfg: UpstreamTarget): Promise { const res = await callUpstream(cfg, { path: '/Users' }); if (!res.ok) throw new Error(`GET /Users → ${res.status}`); const body = (await res.json()) as { Id: string; Name: string }[]; return body.map((u) => ({ Id: u.Id, Name: u.Name })); } /** * Create one account, then sign in as it. * * Two calls, not one — Jellyfin separates creation from everything else, and `POST /Users/New` is the * only one that takes a password. The sign-in that follows is both rule 4's verification and the way the * token is obtained; a create that cannot then authenticate is reported as a failure rather than a user. */ async function createOne( cfg: UpstreamTarget, user: ProvisionableUser, ): Promise<{ jellyfinUserId: string; token: string; deviceId: string }> { const password = generatePassword(); const res = await callUpstream(cfg, { method: 'POST', path: '/Users/New', contentType: 'application/json', body: JSON.stringify({ Name: user.username, Password: password }), }); if (!res.ok) { // 400 here is most likely the name colliding on Jellyfin's uppercased `NormalizedUsername` index, // which the plan should have prevented — so the body is carried out rather than summarised. throw new Error(`POST /Users/New → ${res.status}: ${(await res.text()).slice(0, 200)}`); } const created = (await res.json()) as { Id?: string; Policy?: Record }; if (!created.Id) throw new Error('POST /Users/New returned no Id'); // ── Give them every library ── // // A new Jellyfin user can see NOTHING by default: `EnableAllFolders` is false and `EnabledFolders` is // empty, so the account exists, signs in, and shows an empty home screen. Provisioning that and calling // it done would look like a bug in Officer rather than an unset flag. // // `POST /Users/{id}/Policy` REPLACES the policy rather than merging it — so the whole object goes back, // taken from what Jellyfin just returned and modified by one field. Sending a hand-built policy would // silently reset the 43 properties not mentioned, which is how a user ends up with no transcoding or no // remote access for reasons nobody can trace. await grantAllLibraries(cfg, created.Id, created.Policy); // 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 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 }; } /** * Reconcile Officer's accounts against Jellyfin's, and carry out what is missing. * * Idempotent: run it twice and the second does nothing, because `existing` grows and the plan shrinks. It * does not rely on Jellyfin rejecting a duplicate to stop — that would work, but it would also mean a * normal second run produced errors, and errors that are normal stop being read. * * Never deletes anything. A Jellyfin account with no Officer user is reported and left, because an * absence in Officer is indistinguishable from a list that has not been told about someone yet. */ export async function provision(params: { cfg: UpstreamTarget; users: ProvisionableUser[]; existing: { userId: number; jellyfinUserId: string }[]; }): Promise { const { cfg, users, existing } = params; const jellyfinUsers = await listJellyfinUsers(cfg); const plan: ReconcilePlan = planReconcile({ users, jellyfinUsers, existing }); const outcome: ProvisionOutcome = { linked: [], created: [], untouched: plan.leave, failed: [], log: describePlan(plan), }; // Adoptions first: they touch nothing upstream, so they cannot fail halfway and leave Jellyfin ahead of // the mapping. Doing them first also means a create that fails later does not strand a link that was // already safe to record. for (const { user, jellyfin } of plan.link) { outcome.linked.push({ userId: user.id, jellyfinUserId: jellyfin.Id, username: jellyfin.Name }); } for (const user of plan.create) { try { 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 // gives no clue which ones those were. outcome.failed.push({ username: user.username ?? String(user.id), error: error instanceof Error ? error.message : String(error), }); } } return outcome; } export { normalize };