diff --git a/sidecar/config.ts b/sidecar/config.ts index b4c07c1..c239257 100644 --- a/sidecar/config.ts +++ b/sidecar/config.ts @@ -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 { 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; diff --git a/sidecar/index.ts b/sidecar/index.ts index 58f1d02..c39a904 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -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', diff --git a/sidecar/provision-run.ts b/sidecar/provision-run.ts new file mode 100644 index 0000000..1d58b31 --- /dev/null +++ b/sidecar/provision-run.ts @@ -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 { + 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 }; + } +} diff --git a/sidecar/user-list.ts b/sidecar/user-list.ts new file mode 100644 index 0000000..63af4a7 --- /dev/null +++ b/sidecar/user-list.ts @@ -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;