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. /** Long enough that it does not matter that it is never rotated, since nobody ever knows it. */ const generatePassword = (): string => randomBytes(32).toString('base64url'); export type ProvisionOutcome = { /** Officer user id → what happened. */ linked: { userId: number; jellyfinUserId: string; username: string }[]; created: { userId: number; jellyfinUserId: string; username: string; token: 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 }> { 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 }; if (!created.Id) throw new Error('POST /Users/New returned no Id'); // 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 }; } /** * 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 } = await createOne(cfg, user); outcome.created.push({ userId: user.id, jellyfinUserId, username: user.username!, token }); } 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 };