Files
jellyfin/sidecar/provision.ts
T
pastilhasandClaude Opus 5 f23d8076bf grant every library on creation, by sending back the whole policy
A new Jellyfin user can see nothing: EnableAllFolders is false by default, so a provisioned account
signs in to an empty home screen and looks like a bug in Officer rather than an unset flag.

POST /Users/{id}/Policy replaces rather than merges, so the policy Jellyfin returned from /Users/New
goes straight back with one field flipped. A hand-built body would quietly reset the other 43
properties. EnabledFolders is left as-is — ignored while the blanket flag is on, and still there if
the owner ever turns it off.

Not fatal if it fails: the account exists and works, and failing the create here would leave an
orphan upstream with no mapping row, which is worse than a user who cannot see films yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:34:59 +00:00

181 lines
8.3 KiB
TypeScript

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<string, unknown> | undefined,
): Promise<void> {
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');
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<JellyfinUser[]> {
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; Policy?: Record<string, unknown> };
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 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<ProvisionOutcome> {
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 };