Files
jellyfin/sidecar/provision.ts
T
pastilhas 470d4f83c3 user provisioning, phase one: reconcile against the real server
the platform pushes the account list (users:list, declared by userProvisioning
in the manifest); this decides what to do about each one and does it.

DECIDING IS SEPARATE FROM DOING. reconcile.ts is pure and has no idea what
jellyfin is — which is why it can be tested against the real populations on
alpha without creating anything. nine tests, built on the actual overlap:
diogo and fedra already have jellyfin accounts, conceiba and guipinto do not,
rezio and peter exist only on jellyfin.

that test found a real bug before it shipped. officer's `username` was unique
CASE-SENSITIVELY, so `diogo` and `DIOGO` could both exist here while colliding
on jellyfin's uppercased NormalizedUsername index — and the first version
handed both the same jellyfin account, because it only checked links that
already existed rather than ones claimed during the pass. the platform side is
now case-insensitive too, so the collision cannot be created in the first
place; the guard stays because the constraint and the guard protect different
moments.

three outcomes, and the third is the point: LINK an account that already
exists, CREATE one that does not, LEAVE ALONE a jellyfin account with no
officer user. never delete. an absence in officer is indistinguishable from a
list that has not been told about someone yet, and a wrong deletion costs
somebody their watch history.

`adopted` carries that distinction into the mapping table. false means officer
made the account and may remove it; true means it existed first and was merely
linked.

NOBODY IS EVER TOLD THEIR PASSWORD. jellyfin has no must-change flag — verified
against its openapi, UserPolicy has 44 properties and none of them is one — so
a handed-over password stays valid forever. instead the generated one is used
once, immediately, to mint that person's own access token, and discarded. the
account exists and their officer session can already read their library.

which also makes rule 4 unskippable: the sign-in that verifies the account is
the same call that obtains the token, so there is no version of this that
"verifies later".

one failure does not stop the rest. a partially provisioned server is fixed by
running again; a run that aborts on the first error leaves everyone after them
unprovisioned and no record of who.
2026-08-20 20:14:04 +00:00

135 lines
6.2 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.
/** 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 };
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<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 };