grant every library on creation, by sending back the whole policy

A new Jellyfin user can see nothing: EnableAllFolders is false by default, so a provisioned account
signs in to an empty home screen and looks like a bug in Officer rather than an unset flag.

POST /Users/{id}/Policy replaces rather than merges, so the policy Jellyfin returned from /Users/New
goes straight back with one field flipped. A hand-built body would quietly reset the other 43
properties. EnabledFolders is left as-is — ignored while the blanket flag is on, and still there if
the owner ever turns it off.

Not fatal if it fails: the account exists and works, and failing the create here would leave an
orphan upstream with no mapping row, which is worse than a user who cannot see films yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 20:34:59 +00:00
co-authored by Claude Opus 5
parent d7af39f834
commit f23d8076bf
+48 -2
View File
@@ -24,6 +24,37 @@ import { planReconcile, describePlan, normalize, type JellyfinUser, type Reconci
// 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.
/**
* Turn on access to every library, preserving everything else about the policy.
*
* Not fatal if it fails: the account exists and can sign in, and library access is fixable from Jellyfin's
* own UI. Failing the whole creation here would leave an orphan account upstream with no mapping row,
* which is a worse state than a user who cannot see films yet.
*/
async function grantAllLibraries(
cfg: UpstreamTarget,
jellyfinUserId: string,
policy: Record<string, unknown> | undefined,
): Promise<void> {
if (!policy) {
console.warn(`[jellyfin:provision] ${jellyfinUserId}: no Policy on the created user — library access not set`);
return;
}
const res = await callUpstream(cfg, {
method: 'POST',
path: `/Users/${jellyfinUserId}/Policy`,
contentType: 'application/json',
// `EnabledFolders` is left as Jellyfin returned it. With `EnableAllFolders` true it is ignored, and
// clearing it would lose the per-folder list if the owner ever turns the blanket flag back off.
body: JSON.stringify({ ...policy, EnableAllFolders: true }),
});
if (!res.ok) {
console.error(
`[jellyfin:provision] ${jellyfinUserId}: POST Policy → ${res.status}: ${(await res.text()).slice(0, 200)}`,
);
}
}
/** Long enough that it does not matter that it is never rotated, since nobody ever knows it. */
const generatePassword = (): string => randomBytes(32).toString('base64url');
@@ -72,9 +103,21 @@ async function createOne(
throw new Error(`POST /Users/New → ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
const created = (await res.json()) as { Id?: string };
const created = (await res.json()) as { Id?: string; Policy?: Record<string, unknown> };
if (!created.Id) throw new Error('POST /Users/New returned no Id');
// ── Give them every library ──
//
// A new Jellyfin user can see NOTHING by default: `EnableAllFolders` is false and `EnabledFolders` is
// empty, so the account exists, signs in, and shows an empty home screen. Provisioning that and calling
// it done would look like a bug in Officer rather than an unset flag.
//
// `POST /Users/{id}/Policy` REPLACES the policy rather than merging it — so the whole object goes back,
// taken from what Jellyfin just returned and modified by one field. Sending a hand-built policy would
// silently reset the 43 properties not mentioned, which is how a user ends up with no transcoding or no
// remote access for reasons nobody can trace.
await grantAllLibraries(cfg, created.Id, created.Policy);
// 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);
@@ -124,7 +167,10 @@ export async function provision(params: {
// 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) });
outcome.failed.push({
username: user.username ?? String(user.id),
error: error instanceof Error ? error.message : String(error),
});
}
}