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>
This commit is contained in:
+7
-1
@@ -1,6 +1,7 @@
|
|||||||
export {
|
export {
|
||||||
listJellyfinServers,
|
listJellyfinServers,
|
||||||
getActiveJellyfinCredentials,
|
getActiveJellyfinCredentials,
|
||||||
|
getLinkedJellyfinCredentials,
|
||||||
getJellyfinCredentials,
|
getJellyfinCredentials,
|
||||||
createJellyfinServer,
|
createJellyfinServer,
|
||||||
updateJellyfinServer,
|
updateJellyfinServer,
|
||||||
@@ -9,5 +10,10 @@ export {
|
|||||||
recordJellyfinProbe,
|
recordJellyfinProbe,
|
||||||
} from './queries';
|
} from './queries';
|
||||||
|
|
||||||
export { listJellyfinAccounts, linkJellyfinAccount, unlinkJellyfinAccount } from './queries';
|
export {
|
||||||
|
listJellyfinAccounts,
|
||||||
|
linkJellyfinAccount,
|
||||||
|
setJellyfinAccountToken,
|
||||||
|
unlinkJellyfinAccount,
|
||||||
|
} from './queries';
|
||||||
export type { JellyfinServer, JellyfinCredentials } from './queries';
|
export type { JellyfinServer, JellyfinCredentials } from './queries';
|
||||||
|
|||||||
+85
-4
@@ -1,4 +1,4 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc, isNotNull } from 'drizzle-orm';
|
||||||
import { db } from 'officerdb/db';
|
import { db } from 'officerdb/db';
|
||||||
import { jellyfinServers, jellyfinAccounts } from './schema';
|
import { jellyfinServers, jellyfinAccounts } from './schema';
|
||||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||||
@@ -74,6 +74,60 @@ export async function getActiveJellyfinCredentials(userId: number): Promise<Jell
|
|||||||
return row ? toCredentials(row) : null;
|
return row ? toCredentials(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credentials a NON-OWNER reads the library with: the owner's selected server, and that person's own
|
||||||
|
* token from their link row.
|
||||||
|
*
|
||||||
|
* `id` and `label` are the SERVER's, deliberately — a probe has to be recorded against the row it reached,
|
||||||
|
* and everything downstream of this treats the result as "the connection in use" without caring whose
|
||||||
|
* token is inside it. The only fields that differ from the owner's are the three that must: the token, the
|
||||||
|
* Jellyfin user it belongs to, and the device id its sessions are keyed by.
|
||||||
|
*
|
||||||
|
* Two conditions, and both are the reason this returns null rather than a fallback:
|
||||||
|
*
|
||||||
|
* - **The server must be the ACTIVE one.** Accounts are provisioned against one instance, so a member
|
||||||
|
* linked to the server the owner has since switched away from is genuinely not provisioned on the one
|
||||||
|
* in use. Answering with the other server's URL would hand them a token it does not accept.
|
||||||
|
* - **The token must exist.** An adopted account has none until somebody supplies its password. Null
|
||||||
|
* here becomes the 503 the UI already knows how to explain.
|
||||||
|
*
|
||||||
|
* There is deliberately no path from here to the OWNER's token. A member resolving to the owner's
|
||||||
|
* credential would read the owner's watch state and every library, which is the same failure mode
|
||||||
|
* `getOwnerHomeDir` has on the filesystem side — see `authorize.ts` on confined permissions.
|
||||||
|
*/
|
||||||
|
export async function getLinkedJellyfinCredentials(userId: number): Promise<JellyfinCredentials | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
id: jellyfinServers.id,
|
||||||
|
label: jellyfinServers.label,
|
||||||
|
url: jellyfinServers.url,
|
||||||
|
accessToken: jellyfinAccounts.accessToken,
|
||||||
|
jellyfinUserId: jellyfinAccounts.jellyfinUserId,
|
||||||
|
deviceId: jellyfinAccounts.deviceId,
|
||||||
|
})
|
||||||
|
.from(jellyfinAccounts)
|
||||||
|
.innerJoin(jellyfinServers, eq(jellyfinAccounts.serverId, jellyfinServers.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(jellyfinAccounts.userId, userId),
|
||||||
|
eq(jellyfinServers.isActive, true),
|
||||||
|
isNotNull(jellyfinAccounts.accessToken),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The isNotNull above already excludes it; this narrows the type and keeps the guarantee local.
|
||||||
|
if (!row?.accessToken) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
label: row.label,
|
||||||
|
url: row.url,
|
||||||
|
accessToken: decryptSecret('jellyfin', row.accessToken),
|
||||||
|
jellyfinUserId: row.jellyfinUserId,
|
||||||
|
deviceId: row.deviceId ?? `officer-${userId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
||||||
export async function getJellyfinCredentials(userId: number, id: number): Promise<JellyfinCredentials | null> {
|
export async function getJellyfinCredentials(userId: number, id: number): Promise<JellyfinCredentials | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -205,9 +259,14 @@ export async function recordJellyfinProbe(userId: number, id: number, version: s
|
|||||||
|
|
||||||
// ── The Officer ↔ Jellyfin account mapping ──
|
// ── The Officer ↔ Jellyfin account mapping ──
|
||||||
|
|
||||||
/** Links already recorded for one server, for the reconcile to compare against. */
|
/**
|
||||||
|
* Links already recorded for one server, for the reconcile to compare against.
|
||||||
|
*
|
||||||
|
* `hasToken` rather than the token itself: callers need to know whether this person can reach their
|
||||||
|
* library, and none of them needs the credential to answer that. The ciphertext does not leave this file.
|
||||||
|
*/
|
||||||
export async function listJellyfinAccounts(serverId: number): Promise<
|
export async function listJellyfinAccounts(serverId: number): Promise<
|
||||||
{ userId: number; jellyfinUserId: string; jellyfinUsername: string; adopted: boolean }[]
|
{ userId: number; jellyfinUserId: string; jellyfinUsername: string; adopted: boolean; hasToken: boolean }[]
|
||||||
> {
|
> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -215,10 +274,32 @@ export async function listJellyfinAccounts(serverId: number): Promise<
|
|||||||
jellyfinUserId: jellyfinAccounts.jellyfinUserId,
|
jellyfinUserId: jellyfinAccounts.jellyfinUserId,
|
||||||
jellyfinUsername: jellyfinAccounts.jellyfinUsername,
|
jellyfinUsername: jellyfinAccounts.jellyfinUsername,
|
||||||
adopted: jellyfinAccounts.adopted,
|
adopted: jellyfinAccounts.adopted,
|
||||||
|
accessToken: jellyfinAccounts.accessToken,
|
||||||
})
|
})
|
||||||
.from(jellyfinAccounts)
|
.from(jellyfinAccounts)
|
||||||
.where(eq(jellyfinAccounts.serverId, serverId));
|
.where(eq(jellyfinAccounts.serverId, serverId));
|
||||||
return rows;
|
return rows.map(({ accessToken, ...rest }) => ({ ...rest, hasToken: !!accessToken }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the token minted for one person, encrypted.
|
||||||
|
*
|
||||||
|
* Separate from `linkJellyfinAccount` rather than a parameter on it, because the two answer different
|
||||||
|
* questions and one of them is re-asked. Linking is `onConflictDoNothing` — a second reconcile finding the
|
||||||
|
* same pairing must stay silent — but a token is exactly the thing you re-run provisioning to REPLACE,
|
||||||
|
* after it was revoked from Jellyfin's Devices screen or lost with a server move. An insert that does
|
||||||
|
* nothing on conflict cannot express that.
|
||||||
|
*/
|
||||||
|
export async function setJellyfinAccountToken(params: {
|
||||||
|
userId: number;
|
||||||
|
serverId: number;
|
||||||
|
accessToken: string;
|
||||||
|
deviceId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(jellyfinAccounts)
|
||||||
|
.set({ accessToken: encryptSecret('jellyfin', params.accessToken), deviceId: params.deviceId })
|
||||||
|
.where(and(eq(jellyfinAccounts.userId, params.userId), eq(jellyfinAccounts.serverId, params.serverId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -105,6 +105,29 @@ export const jellyfinAccounts = pgTable(
|
|||||||
normalizedUsername: text('normalized_username').notNull(),
|
normalizedUsername: text('normalized_username').notNull(),
|
||||||
/** False = Officer created it and may remove it. True = it pre-existed and was linked. */
|
/** False = Officer created it and may remove it. True = it pre-existed and was linked. */
|
||||||
adopted: boolean('adopted').notNull().default(false),
|
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(),
|
linkedAt: timestamp('linked_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
|
|||||||
+51
-2
@@ -9,7 +9,7 @@ import {
|
|||||||
updateJellyfinServer,
|
updateJellyfinServer,
|
||||||
} from '../db';
|
} from '../db';
|
||||||
import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream';
|
import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream';
|
||||||
import { runProvisioning } from './provision-run';
|
import { backfillAccountTokens, runProvisioning } from './provision-run';
|
||||||
import { buildAccountView } from './account-view';
|
import { buildAccountView } from './account-view';
|
||||||
|
|
||||||
// `/_config` — the Jellyfin server registry, driven from the app.
|
// `/_config` — the Jellyfin server registry, driven from the app.
|
||||||
@@ -136,7 +136,14 @@ async function addServer(req: Request, userId: number): Promise<Response> {
|
|||||||
jellyfinUserId: auth.userId,
|
jellyfinUserId: auth.userId,
|
||||||
deviceId,
|
deviceId,
|
||||||
});
|
});
|
||||||
return Response.json({ server, provisioning });
|
// And the tokens for anyone this server already knew about. Reconnecting an instance that has been
|
||||||
|
// registered before is the ordinary case for a moved server or a revoked credential, and its accounts
|
||||||
|
// are all "already linked" by then — so provisioning alone would report success and change nothing.
|
||||||
|
const tokens = await backfillAccountTokens({
|
||||||
|
cfg: { base: url, token: auth.token, jellyfinUserId: auth.userId, deviceId },
|
||||||
|
serverId: server.id,
|
||||||
|
});
|
||||||
|
return Response.json({ server, provisioning, tokens });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
|
if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -211,6 +218,43 @@ async function editServer(req: Request, userId: number, id: number): Promise<Res
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-run provisioning against a server already registered, and mint whatever tokens are missing.
|
||||||
|
*
|
||||||
|
* The repair action, and the answer to "members cannot open Video". `addServer` does this once, at
|
||||||
|
* connect; this is the same work without needing the owner to re-type a password they traded away.
|
||||||
|
*
|
||||||
|
* Owner-only WITHOUT a permission rule, and worth saying why out loud: every `/_config` route resolves its
|
||||||
|
* server through `getJellyfinCredentials(userId, id)`, which is scoped to the caller's own rows. A member
|
||||||
|
* holding `jellyfin` at write reaches this handler and gets a 404, because they have no server registry of
|
||||||
|
* their own — the credential they read the library with lives on their link row, not here. So the scoping
|
||||||
|
* that was already there for the registry does this job too.
|
||||||
|
*/
|
||||||
|
async function reprovision(req: Request, userId: number, id: number): Promise<Response> {
|
||||||
|
const creds = await getJellyfinCredentials(userId, id);
|
||||||
|
if (!creds) return bad('no such server', 404);
|
||||||
|
|
||||||
|
const body = ((await req.json().catch(() => null)) as { includeAdopted?: unknown } | null) ?? {};
|
||||||
|
// `=== true` rather than truthy: this decides whether somebody else's Jellyfin password gets reset, and
|
||||||
|
// a stray "false" string must not be the thing that turns it on.
|
||||||
|
const includeAdopted = body.includeAdopted === true;
|
||||||
|
|
||||||
|
const base = normalizeBase(creds.url);
|
||||||
|
const cfg = { base, token: creds.accessToken, jellyfinUserId: creds.jellyfinUserId, deviceId: creds.deviceId };
|
||||||
|
|
||||||
|
const provisioning = await runProvisioning({
|
||||||
|
userId,
|
||||||
|
serverId: id,
|
||||||
|
base,
|
||||||
|
token: creds.accessToken,
|
||||||
|
jellyfinUserId: creds.jellyfinUserId,
|
||||||
|
deviceId: creds.deviceId,
|
||||||
|
});
|
||||||
|
const tokens = await backfillAccountTokens({ cfg, serverId: id, includeAdopted });
|
||||||
|
|
||||||
|
return Response.json({ provisioning, tokens });
|
||||||
|
}
|
||||||
|
|
||||||
/** Check one stored server without switching to it — what the "test" button on each row calls. */
|
/** Check one stored server without switching to it — what the "test" button on each row calls. */
|
||||||
async function testServer(userId: number, id: number): Promise<Response> {
|
async function testServer(userId: number, id: number): Promise<Response> {
|
||||||
const creds = await getJellyfinCredentials(userId, id);
|
const creds = await getJellyfinCredentials(userId, id);
|
||||||
@@ -246,6 +290,11 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s
|
|||||||
return serverList(userId);
|
return serverList(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'provision') {
|
||||||
|
if (req.method !== 'POST') return bad('method not allowed', 405);
|
||||||
|
return reprovision(req, userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
// Who exists on each side, and which pairs are the same person. Read-only and computed on demand —
|
// Who exists on each side, and which pairs are the same person. Read-only and computed on demand —
|
||||||
// nothing is cached, because the interesting case is somebody adding or removing an account in
|
// nothing is cached, because the interesting case is somebody adding or removing an account in
|
||||||
// Jellyfin's own UI and wondering why Officer disagrees.
|
// Jellyfin's own UI and wondering why Officer disagrees.
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import { setProvisionableUsers } from './user-list';
|
|||||||
// for an access token and never stored
|
// for an access token and never stored
|
||||||
// PATCH /_config/:id same fields, all optional; no password keeps the stored token
|
// PATCH /_config/:id same fields, all optional; no password keeps the stored token
|
||||||
// POST /_config/:id/activate switch to that server
|
// POST /_config/:id/activate switch to that server
|
||||||
|
// POST /_config/:id/provision re-run account provisioning and mint any missing member tokens.
|
||||||
|
// `{ includeAdopted }` opts into resetting the password of an
|
||||||
|
// account that pre-dated Officer — off unless asked for
|
||||||
// GET /_config/:id/test probe one server without switching to it
|
// GET /_config/:id/test probe one server without switching to it
|
||||||
// DEL /_config/:id remove it; the newest survivor is promoted if it was active
|
// DEL /_config/:id remove it; the newest survivor is promoted if it was active
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { listJellyfinAccounts, linkJellyfinAccount } from '../db';
|
import { listJellyfinAccounts, linkJellyfinAccount, setJellyfinAccountToken } from '../db';
|
||||||
import { provision, type ProvisionOutcome } from './provision';
|
import { mintTokenForLinked, provision, type ProvisionOutcome } from './provision';
|
||||||
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
|
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
|
||||||
|
import { invalidateConfig, type UpstreamTarget } from './upstream';
|
||||||
|
|
||||||
// The one place that turns "an admin just connected" into accounts on the server.
|
// The one place that turns "an admin just connected" into accounts on the server.
|
||||||
//
|
//
|
||||||
@@ -68,6 +69,16 @@ export async function runProvisioning(params: {
|
|||||||
jellyfinUsername: made.username,
|
jellyfinUsername: made.username,
|
||||||
adopted: false,
|
adopted: false,
|
||||||
});
|
});
|
||||||
|
// The token, which is the half that makes the account reachable. Written as a second statement
|
||||||
|
// because `linkJellyfinAccount` does nothing on conflict — see `setJellyfinAccountToken`. Without
|
||||||
|
// this the person has a Jellyfin account, a link row, and no way to open either.
|
||||||
|
await setJellyfinAccountToken({
|
||||||
|
userId: made.userId,
|
||||||
|
serverId,
|
||||||
|
accessToken: made.token,
|
||||||
|
deviceId: made.deviceId,
|
||||||
|
});
|
||||||
|
invalidateConfig(made.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const line of outcome.log) console.log(`[jellyfin:provision] ${line}`);
|
for (const line of outcome.log) console.log(`[jellyfin:provision] ${line}`);
|
||||||
@@ -87,3 +98,70 @@ export async function runProvisioning(params: {
|
|||||||
return { ran: false, reason };
|
return { ran: false, reason };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BackfillReport = {
|
||||||
|
minted: string[];
|
||||||
|
/** Already had a token. Reported rather than silent, so a re-run visibly does nothing. */
|
||||||
|
alreadyLinked: string[];
|
||||||
|
/** Adopted accounts left alone, and why — the owner has to choose before these can be touched. */
|
||||||
|
skipped: { username: string; reason: string }[];
|
||||||
|
failed: { username: string; error: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a token to every linked account that has none.
|
||||||
|
*
|
||||||
|
* This is the repair for accounts provisioned BEFORE `jellyfin_accounts.access_token` existed. Those rows
|
||||||
|
* record a real Jellyfin account and a real pairing — the only missing piece is the credential, which was
|
||||||
|
* minted at the time and discarded. Nothing here creates or deletes an account; it only fills the gap.
|
||||||
|
*
|
||||||
|
* Idempotent, and cheap when there is nothing to do: an account that already has a token is skipped
|
||||||
|
* without a single upstream call, so this is safe to run on every re-provision.
|
||||||
|
*
|
||||||
|
* `includeAdopted` defaults to FALSE and that default is the safety property. Minting for an adopted
|
||||||
|
* account means resetting a password its owner knows and may use to sign into Jellyfin directly — see
|
||||||
|
* `mintTokenForLinked`. Turning it on is a decision about somebody else's account, so it is never the
|
||||||
|
* default and never inferred.
|
||||||
|
*/
|
||||||
|
export async function backfillAccountTokens(params: {
|
||||||
|
cfg: UpstreamTarget;
|
||||||
|
serverId: number;
|
||||||
|
includeAdopted?: boolean;
|
||||||
|
}): Promise<BackfillReport> {
|
||||||
|
const { cfg, serverId, includeAdopted = false } = params;
|
||||||
|
const report: BackfillReport = { minted: [], alreadyLinked: [], skipped: [], failed: [] };
|
||||||
|
|
||||||
|
for (const account of await listJellyfinAccounts(serverId)) {
|
||||||
|
if (account.hasToken) {
|
||||||
|
report.alreadyLinked.push(account.jellyfinUsername);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (account.adopted && !includeAdopted) {
|
||||||
|
report.skipped.push({
|
||||||
|
username: account.jellyfinUsername,
|
||||||
|
reason: 'this account existed before Officer, so its password is theirs — resetting it would lock them out of Jellyfin itself',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { token, deviceId } = await mintTokenForLinked(cfg, {
|
||||||
|
officerUserId: account.userId,
|
||||||
|
jellyfinUserId: account.jellyfinUserId,
|
||||||
|
username: account.jellyfinUsername,
|
||||||
|
});
|
||||||
|
await setJellyfinAccountToken({ userId: account.userId, serverId, accessToken: token, deviceId });
|
||||||
|
// Their cached config says "nothing configured" and would hold that for up to a minute. They are
|
||||||
|
// most likely staring at the setup form right now, so drop it immediately.
|
||||||
|
invalidateConfig(account.userId);
|
||||||
|
report.minted.push(account.jellyfinUsername);
|
||||||
|
console.log(`[jellyfin:provision] minted a token for ${account.jellyfinUsername}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
report.failed.push({ username: account.jellyfinUsername, error: message });
|
||||||
|
console.error(`[jellyfin:provision] ${account.jellyfinUsername}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|||||||
+63
-6
@@ -58,10 +58,19 @@ async function grantAllLibraries(
|
|||||||
/** Long enough that it does not matter that it is never rotated, since nobody ever knows it. */
|
/** Long enough that it does not matter that it is never rotated, since nobody ever knows it. */
|
||||||
const generatePassword = (): string => randomBytes(32).toString('base64url');
|
const generatePassword = (): string => randomBytes(32).toString('base64url');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DeviceId one person's Officer sessions are keyed by.
|
||||||
|
*
|
||||||
|
* Derived from the Officer user id rather than random, so re-minting a token for somebody reuses their
|
||||||
|
* entry in Jellyfin's Devices screen instead of adding a second ghost beside it — the same reasoning
|
||||||
|
* `editServer` applies when the owner signs in again.
|
||||||
|
*/
|
||||||
|
export const memberDeviceId = (officerUserId: number): string => `officer-${officerUserId}`;
|
||||||
|
|
||||||
export type ProvisionOutcome = {
|
export type ProvisionOutcome = {
|
||||||
/** Officer user id → what happened. */
|
/** Officer user id → what happened. */
|
||||||
linked: { userId: number; jellyfinUserId: string; username: string }[];
|
linked: { userId: number; jellyfinUserId: string; username: string }[];
|
||||||
created: { userId: number; jellyfinUserId: string; username: string; token: string }[];
|
created: { userId: number; jellyfinUserId: string; username: string; token: string; deviceId: string }[];
|
||||||
/** Left alone, reported so the owner can see accounts Officer does not manage. */
|
/** Left alone, reported so the owner can see accounts Officer does not manage. */
|
||||||
untouched: JellyfinUser[];
|
untouched: JellyfinUser[];
|
||||||
failed: { username: string; error: string }[];
|
failed: { username: string; error: string }[];
|
||||||
@@ -87,7 +96,7 @@ export async function listJellyfinUsers(cfg: UpstreamTarget): Promise<JellyfinUs
|
|||||||
async function createOne(
|
async function createOne(
|
||||||
cfg: UpstreamTarget,
|
cfg: UpstreamTarget,
|
||||||
user: ProvisionableUser,
|
user: ProvisionableUser,
|
||||||
): Promise<{ jellyfinUserId: string; token: string }> {
|
): Promise<{ jellyfinUserId: string; token: string; deviceId: string }> {
|
||||||
const password = generatePassword();
|
const password = generatePassword();
|
||||||
|
|
||||||
const res = await callUpstream(cfg, {
|
const res = await callUpstream(cfg, {
|
||||||
@@ -120,8 +129,56 @@ async function createOne(
|
|||||||
|
|
||||||
// Rule 4, and the token in one call. A per-user device id keeps this session distinct from the owner's
|
// 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.
|
// 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);
|
const deviceId = memberDeviceId(user.id);
|
||||||
return { jellyfinUserId: created.Id, token: auth.token };
|
const auth = await authenticate(cfg.base, deviceId, user.username!, password);
|
||||||
|
return { jellyfinUserId: created.Id, token: auth.token, deviceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a token for an account that is ALREADY linked — the repair path, and the only way to give a member
|
||||||
|
* access to an account whose password nobody holds.
|
||||||
|
*
|
||||||
|
* ── Why this has to reset the password ──
|
||||||
|
*
|
||||||
|
* Jellyfin issues a token exactly one way: `AuthenticateByName`, with the password. There is no admin
|
||||||
|
* impersonation endpoint, and its API keys are server-wide and identity-less, so they cannot stand in for
|
||||||
|
* a user (the same reason `ConnectionSection` offers no "paste an API key" field). An administrator CAN
|
||||||
|
* set another user's password without knowing the old one — `POST /Users/{id}/Password` with an empty
|
||||||
|
* `CurrentPw`, verified against 10.11 — so the sequence is: set a fresh random password, spend it once on
|
||||||
|
* a sign-in, discard it. Exactly what `createOne` does, minus the create.
|
||||||
|
*
|
||||||
|
* ── Which accounts this is safe on, and which it is not ──
|
||||||
|
*
|
||||||
|
* Safe on an account Officer created (`adopted: false`). Its password was random and discarded at
|
||||||
|
* creation, so nobody knows it, nothing stores it, and replacing it takes nothing away from anyone.
|
||||||
|
*
|
||||||
|
* NOT safe on an adopted one. That account existed before Officer and its password is somebody's, known
|
||||||
|
* to them and possibly used to sign into Jellyfin directly. Resetting it would lock that person out of
|
||||||
|
* their own account to fix a problem they do not have. The caller decides — see `backfillAccountTokens`,
|
||||||
|
* which refuses adopted accounts unless told otherwise in as many words.
|
||||||
|
*/
|
||||||
|
export async function mintTokenForLinked(
|
||||||
|
cfg: UpstreamTarget,
|
||||||
|
account: { officerUserId: number; jellyfinUserId: string; username: string },
|
||||||
|
): Promise<{ token: string; deviceId: string }> {
|
||||||
|
const password = generatePassword();
|
||||||
|
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
method: 'POST',
|
||||||
|
path: `/Users/${account.jellyfinUserId}/Password`,
|
||||||
|
contentType: 'application/json',
|
||||||
|
// `ResetPassword: false` — that flag CLEARS the password rather than setting it, which would leave the
|
||||||
|
// account reachable by anyone who can see the login screen.
|
||||||
|
body: JSON.stringify({ CurrentPw: '', NewPw: password, ResetPassword: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`POST /Users/${account.jellyfinUserId}/Password → ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = memberDeviceId(account.officerUserId);
|
||||||
|
const auth = await authenticate(cfg.base, deviceId, account.username, password);
|
||||||
|
return { token: auth.token, deviceId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,8 +218,8 @@ export async function provision(params: {
|
|||||||
|
|
||||||
for (const user of plan.create) {
|
for (const user of plan.create) {
|
||||||
try {
|
try {
|
||||||
const { jellyfinUserId, token } = await createOne(cfg, user);
|
const { jellyfinUserId, token, deviceId } = await createOne(cfg, user);
|
||||||
outcome.created.push({ userId: user.id, jellyfinUserId, username: user.username!, token });
|
outcome.created.push({ userId: user.id, jellyfinUserId, username: user.username!, token, deviceId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// One person failing must not stop the rest. A partially provisioned server is recoverable by
|
// 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
|
// running again; a run that aborts on the first error leaves everyone after them unprovisioned and
|
||||||
|
|||||||
+16
-4
@@ -19,7 +19,7 @@
|
|||||||
// 3. Almost every per-user route needs the user id in the PATH (`/Users/{id}/Views`), and it is not
|
// 3. Almost every per-user route needs the user id in the PATH (`/Users/{id}/Views`), and it is not
|
||||||
// derivable from the token without a round trip — which is why it is pinned on the row at sign-in.
|
// derivable from the token without a round trip — which is why it is pinned on the row at sign-in.
|
||||||
|
|
||||||
import { getActiveJellyfinCredentials } from '../db';
|
import { getActiveJellyfinCredentials, getLinkedJellyfinCredentials } from '../db';
|
||||||
|
|
||||||
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
|
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
|
||||||
export type UpstreamTarget = {
|
export type UpstreamTarget = {
|
||||||
@@ -49,14 +49,26 @@ const TTL_MS = 60_000;
|
|||||||
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The owner's SELECTED server, or null when /jellyfin has none yet — the sidecar then answers 503, and the
|
* What THIS account reads Jellyfin with, or null when there is nothing — the sidecar then answers 503, and
|
||||||
* UI turns that into the setup form rather than a screen of empty shelves.
|
* the UI turns that into the setup form rather than a screen of empty shelves.
|
||||||
|
*
|
||||||
|
* Two sources, in this order:
|
||||||
|
*
|
||||||
|
* 1. **A server this account registered itself.** The owner's normal case, and also a member who
|
||||||
|
* deliberately connected a Jellyfin of their own. Checked first so that a person who went to the
|
||||||
|
* trouble of adding one is not overridden by an inherited link.
|
||||||
|
* 2. **A provisioned link on the owner's active server**, carrying that person's OWN token. This is how
|
||||||
|
* a member reaches the library without ever holding a credential or seeing a setup form.
|
||||||
|
*
|
||||||
|
* The second source is what makes `userProvisioning` mean anything. Before 2026-08-21 it did not exist,
|
||||||
|
* so every member fell through to null and got a 503 telling them to connect a Jellyfin server — advice
|
||||||
|
* they could not act on, since the account Officer made for them has a password nobody knows.
|
||||||
*/
|
*/
|
||||||
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
|
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
|
||||||
const hit = cache.get(userId);
|
const hit = cache.get(userId);
|
||||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||||
|
|
||||||
const creds = await getActiveJellyfinCredentials(userId);
|
const creds = (await getActiveJellyfinCredentials(userId)) ?? (await getLinkedJellyfinCredentials(userId));
|
||||||
const cfg = creds
|
const cfg = creds
|
||||||
? {
|
? {
|
||||||
id: creds.id,
|
id: creds.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user