Files
pastilhasandClaude Fable 5 05e11f81c9 store the token provisioning mints, so members can open video
Provisioning already created each member's Jellyfin account and signed in as
them to get a token — its own header says the token exists so "the person's
Officer session can already read their library". It was then dropped, because
linkJellyfinAccount had nowhere to put it.

So every non-owner resolved to no config and got a 503 setup form, asking them
to connect a Jellyfin server with a password nobody knows. It reads as a
permissions fault and is not one: the grant, the role and the account list were
all correct.

jellyfin_accounts now carries access_token and device_id, getConfig falls back
to the link row when the caller has no registry of their own, and there is no
path from there to the owner's token — a member resolving to the owner's
credential would read the owner's watch state and every library.

POST /_config/:id/provision repairs accounts linked before the column existed.
It resets the password to mint, which is free on an account Officer created
(random, discarded, known to nobody) and destructive on an adopted one, so
adopted accounts are skipped unless includeAdopted says otherwise.

Applied on alpha: conceiba and guipinto minted and verified against the live
server; fedra and diogo skipped as adopted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:26:11 +00:00

141 lines
7.9 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),
/**
* That person's OWN Jellyfin access token, encrypted — the credential their Officer session reads the
* library with.
*
* This column is the whole point of the per-user design and it was missing until 2026-08-21.
* `provision.ts` already minted exactly this token, said in its own header that it exists so "the
* person's Officer session can already read their library", and then dropped it on the floor because
* `linkJellyfinAccount` had nowhere to put it. Every non-owner therefore resolved to no config and got
* a 503 setup form, while the owner worked — which reads as a permissions fault and is not one.
*
* NULLABLE, and the null case is normal rather than an error: a token can only be obtained by signing
* in, so an ADOPTED account has none until somebody supplies its password. An Officer-created account
* always has one, because provisioning knows the password it just set.
*/
accessToken: text('access_token'), // encrypted
/**
* The DeviceId this person's sessions are keyed by, `officer-<officer user id>`.
*
* Written with the token and meaningless without it. Distinct from the owner's device id on purpose —
* Jellyfin lists them separately under Devices, so revoking one person's access does not sign
* everybody else out.
*/
deviceId: text('device_id'),
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),
],
);