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>
181 lines
8.3 KiB
TypeScript
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 };
|