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>
This commit is contained in:
2026-08-21 15:51:14 +00:00
co-authored by Claude Fable 5
parent 05e11f81c9
commit 841fb8dcc0
2 changed files with 67 additions and 2 deletions
+5
View File
@@ -5,6 +5,7 @@ import { handleBytesRoute, handleOfficerRoute } from './routes';
import { getConfig, probe } from './upstream';
import { API_URL } from '@@/officer-url.mjs';
import { setProvisionableUsers } from './user-list';
import { provisionForAccountChange } from './provision-run';
// The officer-jellyfin sidecar. Owns the whole Jellyfin contract for Officer: the instance URL, the access
// token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed by. The platform API is a
@@ -146,6 +147,10 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
// not a question.
case 'users:list':
setProvisionableUsers(cmd.users);
// And act on it. Detached deliberately: this reaches out to Jellyfin and can take seconds, while the
// command handler is on the socket the platform is waiting on. Nothing replies to `users:list`, so
// there is no answer to delay — but blocking here would stall every later message on this socket.
void provisionForAccountChange();
break;
default:
reply({
+62 -2
View File
@@ -1,7 +1,13 @@
import { listJellyfinAccounts, linkJellyfinAccount, setJellyfinAccountToken } from '../db';
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, type UpstreamTarget } from './upstream';
import { invalidateConfig, normalizeBase, type UpstreamTarget } from './upstream';
// The one place that turns "an admin just connected" into accounts on the server.
//
@@ -165,3 +171,57 @@ export async function backfillAccountTokens(params: {
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);
}
}