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; +}