Contract for provisioning a user into a plugin's upstream service, with two worked examples. Four rules: never hardcode the admin credential; check-then-create (neither API upserts); force a password change where the server supports it; verify by actually logging in, since a 201 only means the row was written. Immich (photos plugin) — verified end to end against a live 3.1.0 instance, including every error body: duplicate email 400 "Email is not available", missing field 400 validation, bad key 401. Includes a non-destructive probe recipe to re-verify after an upgrade. Jellyfin (jellyfin plugin) — route surface and auth model verified against 10.11.11; request bodies from working knowledge and marked as such. Differs in shape: MediaBrowser token auth, multi-call create, no forced-password-change flag, and accounts are per-instance (this deployment runs four independent servers). Records the decision to set a generated password rather than create blank-password accounts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9.8 KiB
Provisioning a user in a plugin's upstream service
Handing this to a fresh agent? Everything below was verified against a live instance on 2026-08-19 — every status code, every error body. Trust it over the upstream's own docs, which lag. Re-verify with the probe sequence at the end if the upstream has been upgraded since.
Officer creates the account; the upstream service owns it. This is the contract for doing that
safely, written from the photos plugin (Immich) as the first worked example. The shape
generalises: authenticate as an admin, check-then-create, verify the result, hand over a one-time
password.
This directory holds documentation, not plugins. The plugin itself lives in
plugins/photos.
The four rules
- Never hardcode the admin credential. Read it from the environment or the platform's credential store, and never log it. Docs and repos get shared; credentials must not travel with them.
- Check before you create. None of these APIs upsert. A second call with the same email is an error, not a no-op — so provisioning must be re-runnable by listing first.
- Force a password change. Generate a strong random password, mark it as must-change, and treat it as a one-time handover value. It stops being a secret the moment the user logs in.
- Verify by logging in. A
201means the row was written, not that the account works. Do a real login before reporting success, so failures surface at provisioning time rather than when the person first tries to use it.
Worked example — Immich (photos plugin)
Verified against Immich 3.1.0.
| API root | <base>/api — e.g. http://127.0.0.1:2283/api |
| Auth | header x-api-key: <ADMIN_API_KEY> |
| Admin routes | /api/admin/* — reject non-admin keys with 401 |
Immich stores API keys SHA-256 hashed, so a lost key can only be replaced, never recovered. Mint one in the UI → Account Settings → API Keys.
1. Check (idempotency guard)
GET /api/admin/users
x-api-key: <ADMIN_API_KEY>
→ 200, JSON array. If the target email is present, stop — already provisioned.
2. Create
POST /api/admin/users
x-api-key: <ADMIN_API_KEY>
Content-Type: application/json
{
"email": "person@example.com",
"password": "<generated>",
"name": "Full Name",
"shouldChangePassword": true
}
Required: email, password, name.
| Optional field | Type | Notes |
|---|---|---|
shouldChangePassword |
bool | true ⇒ forced reset at first login. Always send it. |
quotaSizeInBytes |
int | e.g. 1073741824 = 1 GiB. Omit for unlimited. |
storageLabel |
string | Folder name used by the storage template. |
notify |
bool | Emails the new user. Requires SMTP configured upstream. |
→ 201 Created, body carries the new id:
{ "id": "70ee4cd2-…", "email": "person@example.com", "name": "Full Name",
"isAdmin": false, "shouldChangePassword": true, "quotaSizeInBytes": null }
isAdmin cannot be set at creation — promote afterwards via the admin update endpoint.
Names are full Unicode (Bruno Conceição); send UTF-8, no escaping.
3. Verify
POST /api/auth/login
Content-Type: application/json
{ "email": "person@example.com", "password": "<generated>" }
→ 200 with {"name":…,"userEmail":…,"isAdmin":false,"shouldChangePassword":true}.
No API key — this is the ordinary user login path, which is exactly why it proves the account works.
Errors (observed, not guessed)
| Condition | Status | Body |
|---|---|---|
| Email already exists | 400 |
{"message":"Email is not available"} |
Missing password / name |
400 |
{"message":"Validation failed","errors":[{"path":["password"],…}]} |
| Bad or missing API key | 401 |
— |
Deprovisioning
DELETE /api/admin/users/{id}
Content-Type: application/json
{ "force": true }
→ 200 with {"status":"removing","deletedAt":"…"}. Soft-delete; force skips the grace period.
Worked example — Jellyfin (jellyfin plugin)
Same four rules, different shape. Read this section as: the route surface and auth model are verified against Jellyfin 10.11.11; the request bodies are from working knowledge and marked as such — confirm them with the probe recipe below before relying on them in code.
How it differs from Immich — read this first
| Immich | Jellyfin | |
|---|---|---|
| Auth header | x-api-key: <key> |
Authorization: MediaBrowser Token="<key>" |
| Create + configure | one call | two or three — create, then policy, sometimes password |
| Force password change | shouldChangePassword: true |
no equivalent — see below |
| Scope of an account | one server | per instance — see below |
Two consequences that bite:
-
There is no "must change password" flag. Rule 3 cannot be enforced by the server. Decision for this deployment (2026-08-20): set a generated password at creation and tell the user to change it. Do not use the blank-password alternative — Jellyfin permits it, but the account is then open to anyone who can reach the port until the user sets one. So for Jellyfin, rule 3 becomes: still generate a strong unique password, still treat it as a handover value, but the change is advisory rather than forced — which means never reuse a generated password across users or instances, since it may stay valid indefinitely.
-
Users are per-instance, not global. This deployment runs four independent Jellyfin servers, each with its own user database and its own API keys:
Instance Port Server name jellyfin9301 Jelly jellyfin_music9302 Albums jellyfin_dj_sets9303 DJ_Sets jellyfin_diogo9399 JellyDiogo Provisioning "a Jellyfin user" therefore means looping over whichever instances that person should reach, with a separate API key per instance. There is no cross-server account.
Getting an API key
Unlike Immich, Jellyfin has no admin-token bootstrap from nothing — creating a key requires an already-authenticated session. Two routes:
- UI: Dashboard → API Keys →
+. Simplest, and the one to use for first setup. - API:
POST /Auth/Keys?App=<name>(verified: route exists,401unauthenticated), using a token obtained fromPOST /Users/AuthenticateByNamewith an admin username/password (verified: route exists; returns415if you omit the JSON content type).
GET /Auth/Keys lists them (verified: exists, 401 unauthenticated). Keys are stored in
config/data/jellyfin.db, table ApiKeys. This deployment currently has zero API keys — one
must be created before any of the flow below will work.
The flow
GET /Users
Authorization: MediaBrowser Token="<API_KEY>"
→ verified: exists, 401 without auth. Use it as the check step (rule 2) — match on Name.
POST /Users/New
Authorization: MediaBrowser Token="<API_KEY>"
Content-Type: application/json
{ "Name": "Full Name", "Password": "<generated>" }
→ verified: route exists (401 unauthenticated). Body shape from working knowledge. Returns the
created user object including its Id (a GUID), which the next calls need.
Then, as needed:
| Purpose | Endpoint | Notes |
|---|---|---|
| Library access, admin rights, parental rating | POST /Users/{id}/Policy |
Full policy object; send the whole thing, it replaces rather than merges. This is where IsAdministrator and EnabledFolders live. |
| Set/replace password | POST /Users/{id}/Password |
Needed if you created the user without one. |
| Delete | DELETE /Users/{id} |
Hard delete — no soft-delete/grace period, unlike Immich. |
Verify (rule 4) with a real login rather than trusting the create:
POST /Users/AuthenticateByName
Content-Type: application/json
{ "Username": "Full Name", "Pw": "<generated>" }
→ returns an AccessToken and the User object on success. Note the field is Pw, not
Password, and Jellyfin authenticates by username, not email — Jellyfin has no concept of an
email login.
A public endpoint worth knowing
GET /Users/Public needs no authentication at all (verified: 200). It lists users flagged
as visible on the login screen. On this deployment it returns [] on all four instances, so
nothing is currently exposed — but if you provision users, be aware that leaving them
login-screen-visible publishes their names to anyone who can reach the port.
Probe recipe (Jellyfin)
Same spirit as the Immich one, but you need a key first, and there is no soft-delete — so probe on a non-production instance or be prepared to hard-delete:
GET /Userswith the key →200and an array. Wrong key →401.POST /Users/Newwith a throwaway name → expect the created object; keep theId.- Repeat the identical call → observe the duplicate-name behaviour and record the exact status and body here, since it is the guard rule 2 depends on.
POST /Users/AuthenticateByNamewith the throwaway credentials → expect anAccessToken.DELETE /Users/{id}→ thenGET /Usersto confirm it is gone.
Re-verifying after an upstream upgrade
Cheap and non-destructive — create a probe user on an unroutable domain, exercise it, delete it:
POST /api/admin/userswithprobe-delete-me@example.invalid→ expect201, keep theid.- Repeat the identical call → expect the duplicate-email
400. POSTwithpassword/nameomitted → expect the validation400.POSTwith a bogus key → expect401.DELETE /api/admin/users/{id}with{"force":true}→ expect200, thenGETto confirm it's gone.
Use .invalid (RFC 2606 — guaranteed never to resolve) so a stray notification email can never
reach a real person.