Files
jellyfin/sidecar/provision-run.ts
T
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

168 lines
7.3 KiB
TypeScript

import { listJellyfinAccounts, linkJellyfinAccount, setJellyfinAccountToken } from '../db';
import { mintTokenForLinked, provision, type ProvisionOutcome } from './provision';
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
import { invalidateConfig, type UpstreamTarget } from './upstream';
// The one place that turns "an admin just connected" into accounts on the server.
//
// Sits between `addServer` and `provision()` so neither has to know about the other: `provision` talks to
// Jellyfin and returns what it did, this writes it down, and `addServer` only has to say when.
//
// ── Why it never throws ──
//
// A failure here must not fail the connection. Registering the server is the thing the owner asked for and
// it has already succeeded by the time this runs — losing it because provisioning went wrong would leave
// them with no media access AND no accounts, which is strictly worse than the accounts alone. So the
// outcome is reported in the response and the server stays registered either way.
export type ProvisioningReport =
| { ran: false; reason: string }
| { ran: true; linked: number; created: number; untouched: number; failed: { username: string; error: string }[]; log: string[] };
export async function runProvisioning(params: {
userId: number;
serverId: number;
base: string;
token: string;
/** The ADMIN's own Jellyfin id — part of `UpstreamTarget`, not who is being provisioned. */
jellyfinUserId: string;
deviceId: string;
}): Promise<ProvisioningReport> {
const { userId, serverId, base, token, jellyfinUserId, deviceId } = params;
// Never told, versus told there is nobody. The first means the push has not arrived or this plugin is
// not entitled to it; the second is a server with only an owner. Provisioning nothing is correct for
// one of those and a silent failure for the other.
if (!hasProvisionableUsers()) {
return { ran: false, reason: 'the platform has not sent the account list yet' };
}
const users = getProvisionableUsers();
if (users.length === 0) return { ran: false, reason: 'no accounts to provision — this server has only an owner' };
try {
const existing = await listJellyfinAccounts(serverId);
const outcome: ProvisionOutcome = await provision({
cfg: { base, token, jellyfinUserId, deviceId },
users,
existing: existing.map((e) => ({ userId: e.userId, jellyfinUserId: e.jellyfinUserId })),
});
// Written AFTER the upstream work, one row at a time. A create that succeeded upstream but whose row
// fails to write is the bad case — it leaves an orphan Jellyfin account Officer will try to create
// again — so each is recorded as soon as it is known rather than in one batch at the end.
for (const link of outcome.linked) {
// `adopted: true` — this account existed before Officer. It must never be deleted on deprovision.
await linkJellyfinAccount({
userId: link.userId,
serverId,
jellyfinUserId: link.jellyfinUserId,
jellyfinUsername: link.username,
adopted: true,
});
}
for (const made of outcome.created) {
await linkJellyfinAccount({
userId: made.userId,
serverId,
jellyfinUserId: made.jellyfinUserId,
jellyfinUsername: made.username,
adopted: false,
});
// The token, which is the half that makes the account reachable. Written as a second statement
// because `linkJellyfinAccount` does nothing on conflict — see `setJellyfinAccountToken`. Without
// this the person has a Jellyfin account, a link row, and no way to open either.
await setJellyfinAccountToken({
userId: made.userId,
serverId,
accessToken: made.token,
deviceId: made.deviceId,
});
invalidateConfig(made.userId);
}
for (const line of outcome.log) console.log(`[jellyfin:provision] ${line}`);
for (const f of outcome.failed) console.error(`[jellyfin:provision] ${f.username}: ${f.error}`);
return {
ran: true,
linked: outcome.linked.length,
created: outcome.created.length,
untouched: outcome.untouched.length,
failed: outcome.failed,
log: outcome.log,
};
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.error('[jellyfin:provision] failed:', reason);
return { ran: false, reason };
}
}
export type BackfillReport = {
minted: string[];
/** Already had a token. Reported rather than silent, so a re-run visibly does nothing. */
alreadyLinked: string[];
/** Adopted accounts left alone, and why — the owner has to choose before these can be touched. */
skipped: { username: string; reason: string }[];
failed: { username: string; error: string }[];
};
/**
* Give a token to every linked account that has none.
*
* This is the repair for accounts provisioned BEFORE `jellyfin_accounts.access_token` existed. Those rows
* record a real Jellyfin account and a real pairing — the only missing piece is the credential, which was
* minted at the time and discarded. Nothing here creates or deletes an account; it only fills the gap.
*
* Idempotent, and cheap when there is nothing to do: an account that already has a token is skipped
* without a single upstream call, so this is safe to run on every re-provision.
*
* `includeAdopted` defaults to FALSE and that default is the safety property. Minting for an adopted
* account means resetting a password its owner knows and may use to sign into Jellyfin directly — see
* `mintTokenForLinked`. Turning it on is a decision about somebody else's account, so it is never the
* default and never inferred.
*/
export async function backfillAccountTokens(params: {
cfg: UpstreamTarget;
serverId: number;
includeAdopted?: boolean;
}): Promise<BackfillReport> {
const { cfg, serverId, includeAdopted = false } = params;
const report: BackfillReport = { minted: [], alreadyLinked: [], skipped: [], failed: [] };
for (const account of await listJellyfinAccounts(serverId)) {
if (account.hasToken) {
report.alreadyLinked.push(account.jellyfinUsername);
continue;
}
if (account.adopted && !includeAdopted) {
report.skipped.push({
username: account.jellyfinUsername,
reason: 'this account existed before Officer, so its password is theirs — resetting it would lock them out of Jellyfin itself',
});
continue;
}
try {
const { token, deviceId } = await mintTokenForLinked(cfg, {
officerUserId: account.userId,
jellyfinUserId: account.jellyfinUserId,
username: account.jellyfinUsername,
});
await setJellyfinAccountToken({ userId: account.userId, serverId, accessToken: token, deviceId });
// Their cached config says "nothing configured" and would hold that for up to a minute. They are
// most likely staring at the setup form right now, so drop it immediately.
invalidateConfig(account.userId);
report.minted.push(account.jellyfinUsername);
console.log(`[jellyfin:provision] minted a token for ${account.jellyfinUsername}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
report.failed.push({ username: account.jellyfinUsername, error: message });
console.error(`[jellyfin:provision] ${account.jellyfinUsername}: ${message}`);
}
}
return report;
}