# 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`](https://gitea.officer.dev/plugins/photos). --- ## The four rules 1. **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. 2. **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. 3. **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. 4. **Verify by logging in.** A `201` means 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 | `/api` — e.g. `http://127.0.0.1:2283/api` | | Auth | header `x-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) ```http GET /api/admin/users x-api-key: ``` → `200`, JSON array. If the target email is present, stop — already provisioned. ### 2. Create ```http POST /api/admin/users x-api-key: Content-Type: application/json { "email": "person@example.com", "password": "", "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`: ```json { "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 ```http POST /api/auth/login Content-Type: application/json { "email": "person@example.com", "password": "" } ``` → `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 ```http 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: ` | `Authorization: MediaBrowser Token=""` | | 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: 1. **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. 2. **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 | |---|---|---| | `jellyfin` | 9301 | Jelly | | `jellyfin_music` | 9302 | Albums | | `jellyfin_dj_sets` | 9303 | DJ_Sets | | `jellyfin_diogo` | 9399 | 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=` (verified: route exists, `401` unauthenticated), using a token obtained from `POST /Users/AuthenticateByName` with an admin username/password (verified: route exists; returns `415` if 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 ```http GET /Users Authorization: MediaBrowser Token="" ``` → verified: exists, `401` without auth. Use it as the **check** step (rule 2) — match on `Name`. ```http POST /Users/New Authorization: MediaBrowser Token="" Content-Type: application/json { "Name": "Full Name", "Password": "" } ``` → 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: ```http POST /Users/AuthenticateByName Content-Type: application/json { "Username": "Full Name", "Pw": "" } ``` → 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: 1. `GET /Users` with the key → `200` and an array. Wrong key → `401`. 2. `POST /Users/New` with a throwaway name → expect the created object; keep the `Id`. 3. 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. 4. `POST /Users/AuthenticateByName` with the throwaway credentials → expect an `AccessToken`. 5. `DELETE /Users/{id}` → then `GET /Users` to 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: 1. `POST /api/admin/users` with `probe-delete-me@example.invalid` → expect `201`, keep the `id`. 2. Repeat the identical call → expect the duplicate-email `400`. 3. `POST` with `password`/`name` omitted → expect the validation `400`. 4. `POST` with a bogus key → expect `401`. 5. `DELETE /api/admin/users/{id}` with `{"force":true}` → expect `200`, then `GET` to confirm it's gone. Use `.invalid` (RFC 2606 — guaranteed never to resolve) so a stray notification email can never reach a real person.