Files
jellyfin/sidecar/provision-run.ts
pastilhasandClaude Fable 5 841fb8dcc0 provision when the account list changes, not only when you connect
Same gap as the photos plugin: users:list updated an in-memory array and
nothing acted on it, so a person added to Officer later reached Video only if
somebody re-ran provisioning by hand. The platform broadcasts on every create
and delete and re-sends on every registration — nothing was listening.

Safe to run unattended without changing what it does, because runProvisioning
already draws the line in the right place: it creates the missing accounts and
tokens them, and it RECORDS an adopted account without touching it. Minting for
an adopted account resets a password its owner knows, and that stays behind
includeAdopted, which only a person can ask for.

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

228 lines
10 KiB
TypeScript

import { OWNER_USER_ID } from 'officerdb';
import {
getActiveJellyfinCredentials,
listJellyfinAccounts,
linkJellyfinAccount,
setJellyfinAccountToken,
} from '../db';
import { mintTokenForLinked, provision, type ProvisionOutcome } from './provision';
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
import { invalidateConfig, normalizeBase, 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;
}
/**
* Provision because the ACCOUNT LIST changed, rather than because somebody asked.
*
* This is what makes an Officer account created next month reach Video without anyone remembering that a
* plugin needed telling. `users:list` arrives on every create and delete — and on every sidecar
* registration, so a restart is also a reconciliation.
*
* `runProvisioning` logs its plan either way, so a restart prints a few lines describing the world as this
* plugin sees it; only the "something changed" summary below is suppressed when nothing did.
*
* Safe unattended because of what `runProvisioning` already does and does not do: it creates the accounts
* that are missing and gives each a token, and it RECORDS an adopted account without touching it. Minting
* for an adopted account is the destructive path — it resets a password its owner knows — and that stays
* behind `backfillAccountTokens({ includeAdopted: true })`, which only a person can ask for.
*
* Never throws. It runs detached from the command handler, and a plugin failing to provision must not take
* down the socket carrying the message.
*/
export async function provisionForAccountChange(): Promise<void> {
try {
// The server members inherit is the owner's selected one. A member who registered a Jellyfin of their
// own is not a provisioning target — that is their connection, not a shared library.
const creds = await getActiveJellyfinCredentials(OWNER_USER_ID);
if (!creds) return; // nothing connected yet; connecting is itself a provisioning trigger
const report = await runProvisioning({
userId: OWNER_USER_ID,
serverId: creds.id,
base: normalizeBase(creds.url),
token: creds.accessToken,
jellyfinUserId: creds.jellyfinUserId,
deviceId: creds.deviceId,
});
if (!report.ran) {
console.log(`[jellyfin:provision] not run — ${report.reason}`);
return;
}
if (report.created === 0 && report.linked === 0 && report.failed.length === 0) return; // nothing changed
console.log(
`[jellyfin:provision] account list changed: ${report.created} created, ${report.linked} linked`,
);
if (report.linked > 0) {
console.log(
'[jellyfin:provision] a linked account had a Jellyfin login already and has no token yet — ' +
'POST /_config/<id>/provision with { "includeAdopted": true } to give it one.',
);
}
} catch (error) {
console.error('[jellyfin:provision] automatic run failed:', error instanceof Error ? error.message : error);
}
}