From 470d4f83c3c25a4ecfec261bceb8440300fcacac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 20:14:04 +0000 Subject: [PATCH] user provisioning, phase one: reconcile against the real server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the platform pushes the account list (users:list, declared by userProvisioning in the manifest); this decides what to do about each one and does it. DECIDING IS SEPARATE FROM DOING. reconcile.ts is pure and has no idea what jellyfin is — which is why it can be tested against the real populations on alpha without creating anything. nine tests, built on the actual overlap: diogo and fedra already have jellyfin accounts, conceiba and guipinto do not, rezio and peter exist only on jellyfin. that test found a real bug before it shipped. officer's `username` was unique CASE-SENSITIVELY, so `diogo` and `DIOGO` could both exist here while colliding on jellyfin's uppercased NormalizedUsername index — and the first version handed both the same jellyfin account, because it only checked links that already existed rather than ones claimed during the pass. the platform side is now case-insensitive too, so the collision cannot be created in the first place; the guard stays because the constraint and the guard protect different moments. three outcomes, and the third is the point: LINK an account that already exists, CREATE one that does not, LEAVE ALONE a jellyfin account with no officer user. never delete. an absence in officer is indistinguishable from a list that has not been told about someone yet, and a wrong deletion costs somebody their watch history. `adopted` carries that distinction into the mapping table. false means officer made the account and may remove it; true means it existed first and was merely linked. NOBODY IS EVER TOLD THEIR PASSWORD. jellyfin has no must-change flag — verified against its openapi, UserPolicy has 44 properties and none of them is one — so a handed-over password stays valid forever. instead the generated one is used once, immediately, to mint that person's own access token, and discarded. the account exists and their officer session can already read their library. which also makes rule 4 unskippable: the sign-in that verifies the account is the same call that obtains the token, so there is no version of this that "verifies later". one failure does not stop the rest. a partially provisioned server is fixed by running again; a run that aborts on the first error leaves everyone after them unprovisioned and no record of who. --- db/index.ts | 1 + db/queries.ts | 59 ++++++++++++++++- db/schema.ts | 53 +++++++++++++++ manifest.ts | 8 +++ sidecar/provision.ts | 134 ++++++++++++++++++++++++++++++++++++++ sidecar/reconcile.test.ts | 112 +++++++++++++++++++++++++++++++ sidecar/reconcile.ts | 123 ++++++++++++++++++++++++++++++++++ 7 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 sidecar/provision.ts create mode 100644 sidecar/reconcile.test.ts create mode 100644 sidecar/reconcile.ts diff --git a/db/index.ts b/db/index.ts index b9b5e02..ab69de6 100644 --- a/db/index.ts +++ b/db/index.ts @@ -9,4 +9,5 @@ export { recordJellyfinProbe, } from './queries'; +export { listJellyfinAccounts, linkJellyfinAccount, unlinkJellyfinAccount } from './queries'; export type { JellyfinServer, JellyfinCredentials } from './queries'; diff --git a/db/queries.ts b/db/queries.ts index 00dc753..8d4a149 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -1,6 +1,6 @@ import { eq, and, desc } from 'drizzle-orm'; import { db } from 'officerdb/db'; -import { jellyfinServers } from './schema'; +import { jellyfinServers, jellyfinAccounts } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; // Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and @@ -202,3 +202,60 @@ export async function recordJellyfinProbe(userId: number, id: number, version: s .set({ version, lastSeenAt: new Date(), updatedAt: new Date() }) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))); } + +// ── The Officer ↔ Jellyfin account mapping ── + +/** Links already recorded for one server, for the reconcile to compare against. */ +export async function listJellyfinAccounts(serverId: number): Promise< + { userId: number; jellyfinUserId: string; jellyfinUsername: string; adopted: boolean }[] +> { + const rows = await db + .select({ + userId: jellyfinAccounts.userId, + jellyfinUserId: jellyfinAccounts.jellyfinUserId, + jellyfinUsername: jellyfinAccounts.jellyfinUsername, + adopted: jellyfinAccounts.adopted, + }) + .from(jellyfinAccounts) + .where(eq(jellyfinAccounts.serverId, serverId)); + return rows; +} + +/** + * Record a pairing. + * + * `onConflictDoNothing` on BOTH unique indexes, so a re-run is silent rather than an error — a second + * reconcile finding the same thing already true is the normal case, not a fault. + * + * `adopted` is the load-bearing field: false means Officer created this account and may remove it, true + * means it existed first and was merely linked. Deleting an adopted account would destroy someone's watch + * history because their Officer login went away. + */ +export async function linkJellyfinAccount(params: { + userId: number; + serverId: number; + jellyfinUserId: string; + jellyfinUsername: string; + adopted: boolean; +}): Promise { + await db + .insert(jellyfinAccounts) + .values({ + userId: params.userId, + serverId: params.serverId, + jellyfinUserId: params.jellyfinUserId, + jellyfinUsername: params.jellyfinUsername, + // Mirrors Jellyfin's own `NormalizedUsername`, so matching is a lookup rather than a convention + // every call site has to remember. + normalizedUsername: params.jellyfinUsername.trim().toUpperCase(), + adopted: params.adopted, + }) + .onConflictDoNothing(); +} + +/** Drop a pairing. Removes Officer's RECORD of the account, never the account itself. */ +export async function unlinkJellyfinAccount(userId: number, serverId: number): Promise { + await db + .delete(jellyfinAccounts) + .where(and(eq(jellyfinAccounts.userId, userId), eq(jellyfinAccounts.serverId, serverId))); +} diff --git a/db/schema.ts b/db/schema.ts index 0fc1ed8..2f1fd4b 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -62,3 +62,56 @@ export const jellyfinServers = pgTable( .where(sql`${t.isActive}`), ], ); + +// ── Which Officer account owns which Jellyfin account ── +// +// Officer's users and Jellyfin's users are two populations that overlap. This records where they meet, so +// "does this person already have an account" is answered locally rather than by asking Jellyfin and +// matching on a name. +// +// ── Why `adopted` earns its column ── +// +// Two rows that look identical mean very different things. `adopted = false` is an account Officer +// created and may therefore delete. `adopted = true` is an account that already existed and was merely +// linked — deleting it would destroy someone's watch history and media access because their Officer +// login was removed, which nobody asked for. +// +// On the live server this is not hypothetical: `diogo` and `fedra` had Jellyfin accounts before Officer +// knew about them, and `rezio` and `peter` have accounts with no Officer login at all. +// +// ── Matching is case-folded, because Jellyfin's is ── +// +// Jellyfin stores `Username` as typed but enforces uniqueness on `NormalizedUsername`, an uppercased copy +// under a UNIQUE index (verified in its own SQLite schema, 10.11.11). So `Diogo` and `diogo` cannot both +// exist there, and matching on the raw name would create a duplicate that Jellyfin then refuses. The +// normalised form is stored here too, so the comparison is a lookup rather than a convention someone has +// to remember at every call site. +export const jellyfinAccounts = pgTable( + 'jellyfin_accounts', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** Which server registry row this account lives on. Jellyfin users are per-instance. */ + serverId: integer('server_id') + .notNull() + .references(() => jellyfinServers.id, { onDelete: 'cascade' }), + /** Jellyfin's own user id — a GUID. The only handle its API accepts for policy and delete. */ + jellyfinUserId: text('jellyfin_user_id').notNull(), + /** As Jellyfin holds it, for display. */ + jellyfinUsername: text('jellyfin_username').notNull(), + /** Uppercased, mirroring Jellyfin's own `NormalizedUsername`. What matching compares. */ + normalizedUsername: text('normalized_username').notNull(), + /** False = Officer created it and may remove it. True = it pre-existed and was linked. */ + adopted: boolean('adopted').notNull().default(false), + linkedAt: timestamp('linked_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + // One Jellyfin account per Officer user per server. uniqueIndex, not unique().on() — drizzle-kit + // re-creates named composite CONSTRAINTS on every push. See src/databases/CLAUDE.md. + uniqueIndex('uq_jellyfin_accounts_user_server').on(table.userId, table.serverId), + // And one Officer user per Jellyfin account: two people cannot share a login. + uniqueIndex('uq_jellyfin_accounts_server_jellyfin_user').on(table.serverId, table.jellyfinUserId), + ], +); diff --git a/manifest.ts b/manifest.ts index fa950b5..3929f48 100644 --- a/manifest.ts +++ b/manifest.ts @@ -37,6 +37,14 @@ export const manifest: PluginManifest = { icon: 'Clapperboard', color: '#a855f7', + // Jellyfin accounts are created one per Officer account. Declared here so the owner sees it on the + // install screen beside the permissions — "this plugin will be told who your users are" belongs where a + // person decides, not in a log afterwards. + // + // What it turns on: the sidecar receives `users:list` when it registers and whenever an account is + // created or removed. See src/servers/plugins/user-provisioning.ts and plugins/USER_PROVISIONING.md. + userProvisioning: true, + permissions: [ { key: 'jellyfin', diff --git a/sidecar/provision.ts b/sidecar/provision.ts new file mode 100644 index 0000000..ae5ba6a --- /dev/null +++ b/sidecar/provision.ts @@ -0,0 +1,134 @@ +import { randomBytes } from 'node:crypto'; +import type { ProvisionableUser } from 'officerdb'; +import { authenticate, callUpstream, type UpstreamTarget } from './upstream'; +import { planReconcile, describePlan, normalize, type JellyfinUser, type ReconcilePlan } from './reconcile'; + +// Carrying out the plan. `reconcile.ts` decides; this does, and does nothing else. +// +// ── Nobody is ever told their Jellyfin password ── +// +// The plan for this deployment is that Jellyfin's own web UI is never exposed: people reach their media +// through Officer, and Officer holds the credential. So the generated password is not a handover value — +// it is used once, immediately, to mint that person's own access token, and then discarded. +// +// That is what makes `USER_PROVISIONING.md` rule 3 survivable here. Jellyfin has no must-change-password +// flag (verified against its OpenAPI: `UserPolicy` has 44 properties and none of them is one), so a +// handed-over password would stay valid indefinitely. A password nobody ever learns cannot. +// +// It also means provisioning produces something immediately useful rather than a credential in an email: +// the account exists AND the person's Officer session can already read their library. +// +// ── Verify by using it, not by reading the 200 ── +// +// Rule 4. `POST /Users/New` returning a user object means a row was written, not that the account works — +// and here the sign-in is not merely a check, it is how the token is obtained. The verification and the +// useful work are the same call, which is the only reason it cannot be skipped when someone is in a hurry. + +/** Long enough that it does not matter that it is never rotated, since nobody ever knows it. */ +const generatePassword = (): string => randomBytes(32).toString('base64url'); + +export type ProvisionOutcome = { + /** Officer user id → what happened. */ + linked: { userId: number; jellyfinUserId: string; username: string }[]; + created: { userId: number; jellyfinUserId: string; username: string; token: string }[]; + /** Left alone, reported so the owner can see accounts Officer does not manage. */ + untouched: JellyfinUser[]; + failed: { username: string; error: string }[]; + /** One line per decision, for the install log. */ + log: string[]; +}; + +/** Everything Jellyfin knows about, as `GET /Users` reports it. */ +export async function listJellyfinUsers(cfg: UpstreamTarget): Promise { + const res = await callUpstream(cfg, { path: '/Users' }); + if (!res.ok) throw new Error(`GET /Users → ${res.status}`); + const body = (await res.json()) as { Id: string; Name: string }[]; + return body.map((u) => ({ Id: u.Id, Name: u.Name })); +} + +/** + * Create one account, then sign in as it. + * + * Two calls, not one — Jellyfin separates creation from everything else, and `POST /Users/New` is the + * only one that takes a password. The sign-in that follows is both rule 4's verification and the way the + * token is obtained; a create that cannot then authenticate is reported as a failure rather than a user. + */ +async function createOne( + cfg: UpstreamTarget, + user: ProvisionableUser, +): Promise<{ jellyfinUserId: string; token: string }> { + const password = generatePassword(); + + const res = await callUpstream(cfg, { + method: 'POST', + path: '/Users/New', + contentType: 'application/json', + body: JSON.stringify({ Name: user.username, Password: password }), + }); + + if (!res.ok) { + // 400 here is most likely the name colliding on Jellyfin's uppercased `NormalizedUsername` index, + // which the plan should have prevented — so the body is carried out rather than summarised. + throw new Error(`POST /Users/New → ${res.status}: ${(await res.text()).slice(0, 200)}`); + } + + const created = (await res.json()) as { Id?: string }; + if (!created.Id) throw new Error('POST /Users/New returned no Id'); + + // Rule 4, and the token in one call. A per-user device id keeps this session distinct from the owner's + // in Jellyfin's Devices screen, so revoking one does not sign out the other. + const auth = await authenticate(cfg.base, `officer-${user.id}`, user.username!, password); + return { jellyfinUserId: created.Id, token: auth.token }; +} + +/** + * Reconcile Officer's accounts against Jellyfin's, and carry out what is missing. + * + * Idempotent: run it twice and the second does nothing, because `existing` grows and the plan shrinks. It + * does not rely on Jellyfin rejecting a duplicate to stop — that would work, but it would also mean a + * normal second run produced errors, and errors that are normal stop being read. + * + * Never deletes anything. A Jellyfin account with no Officer user is reported and left, because an + * absence in Officer is indistinguishable from a list that has not been told about someone yet. + */ +export async function provision(params: { + cfg: UpstreamTarget; + users: ProvisionableUser[]; + existing: { userId: number; jellyfinUserId: string }[]; +}): Promise { + const { cfg, users, existing } = params; + + const jellyfinUsers = await listJellyfinUsers(cfg); + const plan: ReconcilePlan = planReconcile({ users, jellyfinUsers, existing }); + + const outcome: ProvisionOutcome = { + linked: [], + created: [], + untouched: plan.leave, + failed: [], + log: describePlan(plan), + }; + + // Adoptions first: they touch nothing upstream, so they cannot fail halfway and leave Jellyfin ahead of + // the mapping. Doing them first also means a create that fails later does not strand a link that was + // already safe to record. + for (const { user, jellyfin } of plan.link) { + outcome.linked.push({ userId: user.id, jellyfinUserId: jellyfin.Id, username: jellyfin.Name }); + } + + for (const user of plan.create) { + try { + const { jellyfinUserId, token } = await createOne(cfg, user); + outcome.created.push({ userId: user.id, jellyfinUserId, username: user.username!, token }); + } catch (error) { + // One person failing must not stop the rest. A partially provisioned server is recoverable by + // running again; a run that aborts on the first error leaves everyone after them unprovisioned and + // gives no clue which ones those were. + outcome.failed.push({ username: user.username ?? String(user.id), error: error instanceof Error ? error.message : String(error) }); + } + } + + return outcome; +} + +export { normalize }; diff --git a/sidecar/reconcile.test.ts b/sidecar/reconcile.test.ts new file mode 100644 index 0000000..a9b1c33 --- /dev/null +++ b/sidecar/reconcile.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'bun:test'; +import { planReconcile, normalize, type JellyfinUser } from './reconcile'; +import type { ProvisionableUser } from 'officerdb'; + +const user = (id: number, username: string | null): ProvisionableUser => + ({ id, username, email: `${username ?? id}@example.invalid`, name: null, role: 'Member' }); + +// The real populations on alpha as of 2026-08-20, read from Jellyfin's own SQLite and from +// `listProvisionableUsers()`. Using them rather than invented names because every interesting case is +// already present, and because a test built on the real overlap fails when the real overlap changes. +const PLATFORM = [user(24, 'conceiba'), user(25, 'guipinto'), user(26, 'fedra'), user(27, 'diogo')]; +const JELLYFIN: JellyfinUser[] = [ + { Id: 'C9197AD7', Name: 'pastilhas' }, // the owner — admin, never provisioned + { Id: 'B18B7F9B', Name: 'diogo' }, + { Id: 'CC733AF9', Name: 'fedra' }, + { Id: '8186CDB7', Name: 'rezio' }, // no Officer account + { Id: 'BB1486CC', Name: 'peter' }, // no Officer account +]; + +describe('the live server, first run', () => { + const plan = planReconcile({ users: PLATFORM, jellyfinUsers: JELLYFIN, existing: [] }); + + test('links the two that already exist', () => { + expect(plan.link.map((l) => l.user.username).sort()).toEqual(['diogo', 'fedra']); + expect(plan.link.find((l) => l.user.username === 'diogo')!.jellyfin.Id).toBe('B18B7F9B'); + }); + + test('creates only the two that do not', () => { + expect(plan.create.map((u) => u.username).sort()).toEqual(['conceiba', 'guipinto']); + }); + + // The owner is absent from `users` by construction — they hold the admin account this authenticates as. + // So they surface as someone to LEAVE ALONE, which is correct: nothing should touch that account. + test('leaves the orphans and the owner alone', () => { + expect(plan.leave.map((j) => j.Name).sort()).toEqual(['pastilhas', 'peter', 'rezio']); + }); +}); + +describe('running it twice', () => { + // Idempotency is the whole point of the mapping table: the second run must do nothing, and must not + // depend on Jellyfin's duplicate-name error to stop it. + test('is a no-op once everything is linked', () => { + const first = planReconcile({ users: PLATFORM, jellyfinUsers: JELLYFIN, existing: [] }); + const existing = [ + ...first.link.map((l) => ({ userId: l.user.id, jellyfinUserId: l.jellyfin.Id })), + // as if the two creates had happened + { userId: 24, jellyfinUserId: 'NEW-CONCEIBA' }, + { userId: 25, jellyfinUserId: 'NEW-GUIPINTO' }, + ]; + const after = planReconcile({ + users: PLATFORM, + jellyfinUsers: [...JELLYFIN, { Id: 'NEW-CONCEIBA', Name: 'conceiba' }, { Id: 'NEW-GUIPINTO', Name: 'guipinto' }], + existing, + }); + expect(after.link).toEqual([]); + expect(after.create).toEqual([]); + expect(after.brokenLinks).toEqual([]); + expect(after.unchanged.length).toBe(4); + }); +}); + +describe('case', () => { + // Jellyfin enforces uniqueness on an uppercased NormalizedUsername under a UNIQUE index. A raw + // comparison would decide `Diogo` needs creating, and Jellyfin would refuse the create on that index — + // an install failing on a constraint invisible from this side. + test('matches across case, because Jellyfin does', () => { + const plan = planReconcile({ + users: [user(27, 'diogo')], + jellyfinUsers: [{ Id: 'B18B7F9B', Name: 'DIOGO' }], + existing: [], + }); + expect(plan.create).toEqual([]); + expect(plan.link).toHaveLength(1); + }); + + test('normalize matches Jellyfin NormalizedUsername', () => { + expect(normalize(' Diogo ')).toBe('DIOGO'); + }); +}); + +describe('the awkward ones', () => { + // Jellyfin authenticates by name and has no email login, so an account with no username cannot be + // matched or created. Guessing one from the email's local part would claim a name nobody chose. + test('an Officer user with no username is skipped, not invented', () => { + const plan = planReconcile({ users: [user(99, null)], jellyfinUsers: [], existing: [] }); + expect(plan.create).toEqual([]); + expect(plan.link).toEqual([]); + }); + + // Someone deleted the Jellyfin account in its own UI. Reported as broken rather than folded into + // `create`, because "a new person" and "an account vanished behind our back" are different events. + test('a link whose Jellyfin account is gone is reported as broken', () => { + const plan = planReconcile({ + users: [user(27, 'diogo')], + jellyfinUsers: [], + existing: [{ userId: 27, jellyfinUserId: 'B18B7F9B' }], + }); + expect(plan.brokenLinks).toEqual([{ userId: 27, jellyfinUserId: 'B18B7F9B' }]); + expect(plan.create).toEqual([]); + }); + + // Two Officer accounts cannot adopt one Jellyfin login. The second is left for a human. + test('a Jellyfin account already claimed is not adopted twice', () => { + const plan = planReconcile({ + users: [user(27, 'diogo'), user(28, 'DIOGO')], + jellyfinUsers: [{ Id: 'B18B7F9B', Name: 'diogo' }], + existing: [], + }); + expect(plan.link).toHaveLength(1); + expect(plan.create).toEqual([]); + }); +}); diff --git a/sidecar/reconcile.ts b/sidecar/reconcile.ts new file mode 100644 index 0000000..58f6bd2 --- /dev/null +++ b/sidecar/reconcile.ts @@ -0,0 +1,123 @@ +import type { ProvisionableUser } from 'officerdb'; + +// Deciding what to do about each account, with no side effects. +// +// Separated from the doing on purpose. The execution talks to a live Jellyfin and cannot be rehearsed; +// the decision is where every mistake that matters lives, and it can be checked against the real names on +// the real server without creating anything. +// +// ── The three outcomes, and why the third is not "delete" ── +// +// LINK an Officer user whose username already exists on Jellyfin. Record the pairing; create nothing. +// CREATE an Officer user with no Jellyfin account. Make one. +// LEAVE a Jellyfin account with no Officer user. Do nothing at all, and say so. +// +// On the live server all three are populated: `diogo` and `fedra` had Jellyfin accounts before Officer +// knew about them, `conceiba` and `guipinto` have none, and `rezio` and `peter` exist only on Jellyfin. +// +// The third outcome is the one worth stating in code rather than leaving implicit. Deleting a Jellyfin +// account because nobody holds a matching Officer login would destroy a person's watch history and media +// access on the strength of an absence — and an absence is exactly what an incomplete list looks like. +// So they are reported, never touched, and the report is the feature: it is how the owner notices that +// two accounts exist which Officer does not manage. +// +// ── Matching is case-folded ── +// +// Jellyfin stores `Username` as typed and enforces uniqueness on `NormalizedUsername`, an uppercased copy +// under a UNIQUE index (read from its own SQLite schema, 10.11.11). So a raw-name comparison would decide +// `Diogo` needs creating when `diogo` exists, and Jellyfin would then refuse the create on that index — +// an install failing on a constraint nobody could see from here. + +/** A Jellyfin account as `GET /Users` reports it. Only the fields a decision needs. */ +export type JellyfinUser = { Id: string; Name: string }; + +/** An existing pairing, so a second run does not re-decide what is already recorded. */ +export type ExistingLink = { userId: number; jellyfinUserId: string }; + +export type ReconcilePlan = { + /** Officer users whose Jellyfin account already exists — record the pairing, create nothing. */ + link: { user: ProvisionableUser; jellyfin: JellyfinUser }[]; + /** Officer users with no Jellyfin account. */ + create: ProvisionableUser[]; + /** Jellyfin accounts with no Officer user. Reported, never touched. */ + leave: JellyfinUser[]; + /** Already linked, and still valid on both sides. Nothing to do. */ + unchanged: ExistingLink[]; + /** + * Links whose Jellyfin account has vanished — deleted in Jellyfin's own UI, most likely. + * + * Separated from `create` even though the remedy is the same, because they are different events: one is + * a new person, the other is someone's account disappearing behind Officer's back. Collapsing them + * would make the second invisible. + */ + brokenLinks: ExistingLink[]; +}; + +/** Jellyfin's own rule, applied on this side so the two agree. */ +export const normalize = (name: string): string => name.trim().toUpperCase(); + +/** + * Work out what should happen. Nothing here talks to anything. + * + * `users` is what the platform pushed (`users:list`), already excluding the owner — they hold the admin + * account this whole flow authenticates as, and provisioning them would be a second admin nobody asked for. + */ +export function planReconcile(params: { + users: ProvisionableUser[]; + jellyfinUsers: JellyfinUser[]; + existing: ExistingLink[]; +}): ReconcilePlan { + const { users, jellyfinUsers, existing } = params; + + const jellyfinByNormalized = new Map(jellyfinUsers.map((j) => [normalize(j.Name), j])); + const jellyfinById = new Map(jellyfinUsers.map((j) => [j.Id, j])); + const linkedUserIds = new Set(existing.map((e) => e.userId)); + const linkedJellyfinIds = new Set(existing.map((e) => e.jellyfinUserId)); + /** Jellyfin accounts spoken for — pre-existing links, plus anything claimed during this pass. */ + const claimed = new Set(linkedJellyfinIds); + + const plan: ReconcilePlan = { link: [], create: [], leave: [], unchanged: [], brokenLinks: [] }; + + for (const link of existing) { + (jellyfinById.has(link.jellyfinUserId) ? plan.unchanged : plan.brokenLinks).push(link); + } + + for (const user of users) { + if (linkedUserIds.has(user.id)) continue; // already recorded — handled above + + // An Officer account with no username cannot be matched or created: Jellyfin authenticates by name and + // has no concept of an email login. Skipped rather than guessed at from the email's local part, which + // would silently claim a name the person did not choose. + if (!user.username) continue; + + const match = jellyfinByNormalized.get(normalize(user.username)); + if (match && !claimed.has(match.Id)) { + plan.link.push({ user, jellyfin: match }); + // Claimed NOW, not just checked against what was already linked. Officer's `username` is unique + // case-SENSITIVELY, so `diogo` and `DIOGO` can both exist here while colliding on Jellyfin's + // uppercased index — and without this the pass hands both the same account. + claimed.add(match.Id); + } else if (!match) { + plan.create.push(user); + } + // A match already claimed falls through deliberately: it is neither this user's account to adopt nor + // safe to duplicate, and `leave` below reports it for a human to settle. + } + + for (const jellyfin of jellyfinUsers) { + if (!claimed.has(jellyfin.Id)) plan.leave.push(jellyfin); + } + + return plan; +} + +/** One line per outcome, for the install log and the panel. Says what will happen before it happens. */ +export function describePlan(plan: ReconcilePlan): string[] { + const lines: string[] = []; + for (const { user, jellyfin } of plan.link) lines.push(`link ${user.username} → existing Jellyfin "${jellyfin.Name}"`); + for (const user of plan.create) lines.push(`create ${user.username}`); + for (const jellyfin of plan.leave) lines.push(`leave "${jellyfin.Name}" alone — no Officer account`); + for (const broken of plan.brokenLinks) lines.push(`link for user ${broken.userId} is broken — Jellyfin account is gone`); + if (plan.unchanged.length) lines.push(`${plan.unchanged.length} already linked`); + return lines; +}