Files
pastilhasandClaude Fable 5 05e11f81c9 store the token provisioning mints, so members can open video
Provisioning already created each member's Jellyfin account and signed in as
them to get a token — its own header says the token exists so "the person's
Officer session can already read their library". It was then dropped, because
linkJellyfinAccount had nowhere to put it.

So every non-owner resolved to no config and got a 503 setup form, asking them
to connect a Jellyfin server with a password nobody knows. It reads as a
permissions fault and is not one: the grant, the role and the account list were
all correct.

jellyfin_accounts now carries access_token and device_id, getConfig falls back
to the link row when the caller has no registry of their own, and there is no
path from there to the owner's token — a member resolving to the owner's
credential would read the owner's watch state and every library.

POST /_config/:id/provision repairs accounts linked before the column existed.
It resets the password to mint, which is free on an account Officer created
(random, discarded, known to nobody) and destructive on an adopted one, so
adopted accounts are skipped unless includeAdopted says otherwise.

Applied on alpha: conceiba and guipinto minted and verified against the live
server; fedra and diogo skipped as adopted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:26:11 +00:00

238 lines
11 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');
/**
* 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<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; 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<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 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<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, 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 };