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.
118 lines
6.5 KiB
TypeScript
118 lines
6.5 KiB
TypeScript
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
import { users } from 'officerdb/auth/schema';
|
|
|
|
// The Jellyfin servers behind /jellyfin, for the officer-jellyfin sidecar.
|
|
//
|
|
// A REGISTRY rather than a single row, and here that is not speculative: this machine runs four Jellyfin
|
|
// instances on separate ports (video, albums, DJ sets, and a second person's), and the owner switches
|
|
// between them. Same shape as invoiceshelf_accounts and offscale_servers.
|
|
//
|
|
// `accessToken` is encrypted at rest via ../crypto.ts. A Jellyfin access token can read every item, every
|
|
// watch history and every stream on the instance, so a DB dump must not hand it over. Encryption is confined
|
|
// to queries/jellyfin.ts; nothing outside that file sees ciphertext, and no route ever returns the token.
|
|
//
|
|
// The PASSWORD is never stored. It is used once, by the sidecar, to trade for an access token through
|
|
// `POST /Users/AuthenticateByName` — the same shape as InvoiceShelf's mintToken.
|
|
export const jellyfinServers = pgTable(
|
|
'jellyfin_servers',
|
|
{
|
|
id: serial('id').primaryKey(),
|
|
userId: integer('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
/** What the owner calls this server. The switcher shows nothing else, so it has to be theirs to set. */
|
|
label: text('label').notNull(),
|
|
// Normalized without a trailing slash before write, so `${url}/Items/...` never doubles the separator.
|
|
url: text('url').notNull(),
|
|
accessToken: text('access_token').notNull(), // encrypted
|
|
/**
|
|
* The Jellyfin user the token belongs to, pinned on the row.
|
|
*
|
|
* Jellyfin's per-user routes (`/UserItems/Resume`, played state, favourites) need this id explicitly, and
|
|
* it is NOT derivable from the token without a round trip. Authenticating returns it, so it is stored
|
|
* once rather than looked up on every request.
|
|
*/
|
|
jellyfinUserId: text('jellyfin_user_id').notNull(),
|
|
/** The account name that was authenticated — shown in the UI so two logins on one server are told apart. */
|
|
jellyfinUsername: text('jellyfin_username'),
|
|
/**
|
|
* The DeviceId sent on every call, generated once when the server is added.
|
|
*
|
|
* Jellyfin keys sessions and "remembered devices" by this. A fresh one per request would litter the
|
|
* dashboard with hundreds of devices and break playback reporting, which correlates by session.
|
|
*/
|
|
deviceId: text('device_id').notNull(),
|
|
/** The instance's own name (`ServerName` from /System/Info/Public), for the switcher. */
|
|
serverName: text('server_name'),
|
|
/** Jellyfin version seen at the last successful probe — shown in the UI, never used for behaviour. */
|
|
version: text('version'),
|
|
isActive: boolean('is_active').notNull().default(false),
|
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
uniqueIndex('uq_jellyfin_servers_user_label').on(t.userId, t.label),
|
|
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
|
|
// index over the active rows only. setActiveJellyfinServer still clears the others in a transaction, but
|
|
// a bug there fails loudly here instead of silently leaving two active and the UI picking one.
|
|
uniqueIndex('uq_jellyfin_servers_one_active')
|
|
.on(t.userId)
|
|
.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),
|
|
],
|
|
);
|