wire the trigger: connecting an admin server provisions everyone
the decision and the execution existed; nothing called them. connecting a server would have given a working media connection and zero accounts. the trigger is registering an ADMIN server, not installing the plugin. at install there is no credential, so there is nothing to provision with — and this is also the right moment for a re-run, since a revoked token or a moved server both come back through the same door. the sidecar now holds the pushed account list in memory. each users:list REPLACES what was held rather than merging: it is the platform's current truth, not a cache. persisting it would create a second copy of the platform's users that could disagree with it, and the recovery from disagreement is re-reading the platform anyway. `hasProvisionableUsers()` distinguishes never-told from told-there-is-nobody. an empty list means a server with only an owner; no list at all means the push has not arrived or the plugin is not entitled. provisioning nothing is right for the first and a silent failure for the second. awaited, so the response says what happened instead of the owner finding it in a log later. and it never throws: registering the server already succeeded by then, and losing it to a provisioning error would leave them with no media access AND no accounts, which is worse than the accounts alone. mapping rows are written one at a time as each result is known, not batched at the end. a create that succeeds upstream but whose row fails to write leaves an orphan account officer will try to create again — the narrower that window, the better. found while wiring: the first edit landed the block in editServer as well as addServer, where `auth` does not exist. caught by tsgo, removed.
This commit is contained in:
+20
-1
@@ -9,6 +9,7 @@ import {
|
||||
updateJellyfinServer,
|
||||
} from '../db';
|
||||
import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream';
|
||||
import { runProvisioning } from './provision-run';
|
||||
|
||||
// `/_config` — the Jellyfin server registry, driven from the app.
|
||||
//
|
||||
@@ -116,7 +117,25 @@ async function addServer(req: Request, userId: number): Promise<Response> {
|
||||
activate,
|
||||
});
|
||||
invalidateConfig(userId);
|
||||
return Response.json({ server });
|
||||
|
||||
// ── Provision, if this connection can ──
|
||||
//
|
||||
// The trigger is registering an ADMIN server, not installing the plugin: at install time there is no
|
||||
// credential, so there is nothing to provision with. This is also the right moment for a re-run — a
|
||||
// token revoked and re-issued, or the server moving, both come back through here.
|
||||
//
|
||||
// Awaited, so the response says what happened rather than the owner discovering it in a log. It can
|
||||
// take a few seconds against a server with many accounts; that is the honest cost of telling the truth
|
||||
// in the reply.
|
||||
const provisioning = await runProvisioning({
|
||||
userId,
|
||||
serverId: server.id,
|
||||
base: url,
|
||||
token: auth.token,
|
||||
jellyfinUserId: auth.userId,
|
||||
deviceId,
|
||||
});
|
||||
return Response.json({ server, provisioning });
|
||||
} catch (err) {
|
||||
if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
|
||||
throw err;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { handleConfigRoute, noteProbe } from './config';
|
||||
import { handleBytesRoute, handleOfficerRoute } from './routes';
|
||||
import { getConfig, probe } from './upstream';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
import { setProvisionableUsers } from './user-list';
|
||||
|
||||
// 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
|
||||
@@ -137,6 +138,12 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
// The platform's account list, pushed because this plugin's manifest declares `userProvisioning`.
|
||||
// Arrives on registration and again on every account create or delete. No reply — it is a statement,
|
||||
// not a question.
|
||||
case 'users:list':
|
||||
setProvisionableUsers(cmd.users);
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { listJellyfinAccounts, linkJellyfinAccount } from '../db';
|
||||
import { provision, type ProvisionOutcome } from './provision';
|
||||
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ProvisionableUser } from 'officerdb';
|
||||
|
||||
// The account list the platform pushes, held for as long as this process lives.
|
||||
//
|
||||
// Pushed rather than fetched — see src/servers/plugins/user-provisioning.ts. It arrives once when this
|
||||
// sidecar registers, and again whenever an Officer account is created or removed, so it is the current
|
||||
// truth rather than a cache with an expiry: each message REPLACES what was held, never merges.
|
||||
//
|
||||
// In memory on purpose. Persisting it would create a second copy of the platform's own users that could
|
||||
// disagree with it, and the recovery from disagreement would be re-reading the platform anyway. A restart
|
||||
// re-registers, which re-sends.
|
||||
|
||||
let users: ProvisionableUser[] = [];
|
||||
let receivedAt: Date | null = null;
|
||||
|
||||
export function setProvisionableUsers(next: ProvisionableUser[]): void {
|
||||
users = next;
|
||||
receivedAt = new Date();
|
||||
console.log(`[jellyfin] user list: ${next.length} account(s) the platform can provision`);
|
||||
}
|
||||
|
||||
export const getProvisionableUsers = (): ProvisionableUser[] => users;
|
||||
|
||||
/**
|
||||
* Whether the platform has told us anything yet.
|
||||
*
|
||||
* The distinction matters: an EMPTY list means "this server has only an owner", while never having been
|
||||
* told means the plugin is not entitled or the push has not arrived. Provisioning nothing is right in the
|
||||
* first case and wrong in the second, so the caller has to be able to tell them apart.
|
||||
*/
|
||||
export const hasProvisionableUsers = (): boolean => receivedAt !== null;
|
||||
Reference in New Issue
Block a user