# Spec: Multi-user profiles with PIN switching **Status:** Proposed **Requirements:** UR-082, UR-083, UR-084 → IR-034, DR-267 … DR-276 **UX spec:** [ux-flows.md](../ux-flows.md) — new "Who's watching" section **Destination on completion:** - [09-security.md](../architecture/09-security.md) — new "Profile locking" section beside *Authentication Token Storage* (PIN gate, what it does and does not protect) - [01-rust-backend.md](../architecture/01-rust-backend.md) — profile switch orchestration beside the session state machine - [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — profile picker + lock state in the nav guard - [08-database-design.md](../architecture/08-database-design.md) — `user_pins`, `user_item_visibility`, `download_grants`, per-user vs device settings - [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — shared files, per-user grants, refcounted deletion ## Summary A shared device (family TV, tablet) can hold several Jellyfin accounts **from the same server** and switch between them in a couple of taps. Adult accounts can set a numeric PIN that gates the switch; child accounts have no PIN and are one tap away. An adult who forgets their PIN signs in with their Jellyfin password instead — there is no separate reset flow. The feature is **opt-in and invisible until used**: one account with no PIN behaves exactly as the app does today. ## Motivation JellyTau already stores users per server ([schema.rs](../../src-tauri/src/storage/schema.rs) `users`), keeps a token per user in the keyring, and exposes `storage_get_users` / `storage_set_active_user` — none of which any UI calls. The only way to change account today is `auth_logout`, which calls Jellyfin's logout endpoint and **invalidates the token server-side**, forcing a full password login every time. On a living-room device shared by a family that is the difference between "switch to the kids' profile" and "find the password". Two defects block simply exposing the existing commands, and both are the real work of this spec: 1. **The metadata cache is server-scoped, not user-scoped.** `items`, `libraries`, `genres` and `thumbnails` carry `server_id` but no user. Jellyfin's parental controls filter *server responses*, but the cache-first read path returns local rows before the server answers — so a child profile on a device a parent has browsed sees the parent's titles and artwork. 2. **Download availability is answered per item, not per user.** `offline_is_available` ([offline.rs](../../src-tauri/src/commands/offline.rs)) counts completed rows for an `item_id` with no user predicate, so a child's UI marks a parent's download as available and can play it offline. ## Layer assignment | Logic / responsibility | Layer | Why it belongs there | |------------------------|-------|----------------------| | Which profiles exist, and each one's unlock method | Rust | Derived from the `users` table + PIN presence. The frontend must never infer "this is a child account" from anything; it renders an opaque `unlock_method` | | PIN verification, attempt counting, lockout window | Rust | A gate the frontend could skip is not a gate. The counter and the clock must live where the webview cannot reach them | | PIN hashing (KDF, salt, cost) | Rust | Security primitive; changes with threat model, never with UI | | Switch orchestration (stop player, drain sync queue, swap repository, restart poller) | Rust | Owns every piece of state being torn down; ordering is a correctness invariant | | Cache visibility stamping and filtering | Rust | Domain data access control. Any leak here is a content-safety bug | | Download grants, refcounted file deletion | Rust | Storage domain; the frontend has no concept of a file refcount | | Same-server constraint on adding a profile | Rust | Domain rule about what a profile *is*, not a form-validation nicety | | Whether to show the picker at startup | Rust | Depends on profile count + PIN presence + a stored setting, all backend state | | Profile picker grid, avatars, transitions | Frontend | Pure presentation | | PIN pad layout, digit entry, shake-on-wrong | Frontend | Input handling; changes only if the UI is redesigned | | "Use password instead" form | Frontend | Presentation over the existing `auth_login` | | Ordering of tiles (last used first) | Frontend | Presentation preference over data Rust already returns | Borderline: *ordering of tiles* could be argued into Rust since `last_used_at` comes from the DB. Rust returns the timestamp; the frontend decides it means "leftmost". Tie-breaker: it changes only if the UI is redesigned. ## Design ### Threat model — state it plainly The PIN is a **switching gate against a member of the household**, not at-rest protection against an attacker with the disk. Tokens stay in the keyring exactly as they are today ([credentials.rs](../../src-tauri/src/credentials.rs)); the PIN does **not** encrypt them. This is a deliberate choice, and the rejected alternative matters enough to record: wrapping each token with a key derived from its PIN would resist an offline attacker, but a locked profile would then be *unable to act as itself* — no resuming its downloads after a restart, no draining its `sync_queue`, no session polling — until someone walked past and typed four digits. On a device that reboots nightly that is a worse product for a threat this feature does not face. A four-digit code was never going to resist an offline attack anyway. Consequences to document in 09-security.md rather than discover later: - Anyone with the SQLite file and keyring access has every profile's token, PIN or not. - The PIN stops a child *becoming a parent*. It does not restrict content. Content restriction is Jellyfin's server-side parental controls, which most self-hosters have never configured — the UI must say so when a PIN-less profile is created. ### Schema ```sql -- Migration 024 CREATE TABLE user_pins ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, pin_hash TEXT NOT NULL, -- Argon2id PHC string; salt is embedded failed_count INTEGER DEFAULT 0, locked_until TEXT, -- RFC3339; NULL when not locked out updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); -- What the server has actually shown to this user. NOT a maintained index: -- written as a byproduct of the cache write path, so it cannot disagree with -- what the server returned. CREATE TABLE user_item_visibility ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, item_id TEXT NOT NULL, seen_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, item_id) ); CREATE INDEX idx_visibility_user ON user_item_visibility(user_id); -- Same, at library granularity, from each user's /UserViews. CREATE TABLE user_libraries ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id TEXT NOT NULL, PRIMARY KEY (user_id, library_id) ); -- Downloads: one file, many claimants. CREATE TABLE download_grants ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE, granted_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, download_id) ); ``` **Migration of existing installs is not optional.** Every current cache row and download predates the concept of a user. Migration 024 backfills `user_item_visibility` and `download_grants` for the single existing user (and, if somehow several `users` rows exist, for the one with `is_active = 1`). Without the backfill an upgrading user's library goes blank. ### Cache scoping — a byproduct, not an index The reason this is tractable: the repository **already carries the user**. ```rust pub struct OfflineRepository { db_service: Arc, server_id: String, user_id: String, // already there, already used for user_data joins } ``` [offline.rs:87](../../src-tauri/src/repository/offline.rs#L87) Every row enters the cache because *some specific user's* request returned it, and `save_to_cache` ([offline.rs:395](../../src-tauri/src/repository/offline.rs#L395)) — the single write choke point, called only from [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) — knows who that is. It stamps `user_item_visibility` in the same transaction as the item. There is no reconciliation job and no way for the stamp to drift from what the server said, because the stamp *is* the record of what the server said. Reads join through it. 43 of the 64 `FROM items` sites live in `repository/offline.rs`, where `self.user_id` is already in scope. **The 21 sites outside the repository are triaged, not blanket-scoped:** | Group | Disposition | |-------|-------------| | Browse/query paths returning lists to the UI | Must scope | | By-id lookups from an already-authorised context (download worker resolving an item it holds a grant for; queued-row stream URL lookup) | Not scoped — authorisation happened upstream | | Maintenance (`smart_cache` eviction, `pinning`) | Not scoped — deliberately device-wide | The triage result is recorded in 08-database-design.md, because "why isn't this one scoped?" is exactly what a future change gets wrong. **Known limit — revocation drift.** The stamp can never show *more* than the server showed, but it does not shrink when a parent tightens permissions. Mitigation: on unlock while online, re-derive `user_libraries` from `/UserViews` and drop visibility rows for libraries that disappeared. Offline, the cache stays stale-permissive. This is a stated property, not a bug. **Enforcement.** `scripts/check-cache-scope.sh` fails if `FROM items` appears outside an allowlist of modules. With writes funnelled and 43 reads in one file the allowlist is short enough to mean something — unlike `check:boundary`, which had to pattern-match literals. It will not catch a missed join *inside* the repository; it will catch a new query appearing in a random command file, which is the realistic drift. ### Downloads — shared files, per-user grants The file layout already assumes sharing: paths are content-derived (`{base}/{series}/{S01E02 - Name}`, [download/mod.rs:1052](../../src-tauri/src/commands/download/mod.rs#L1052)) while rows are keyed `UNIQUE(item_id, user_id)` — so two profiles downloading the same episode already aim at one path and clobber each other. Formalising: - A second profile requesting an already-downloaded item inserts a **grant**. No bytes transferred; immediately available. - `offline_is_available` joins through grants instead of counting rows per item. - `download_cancel` ([download/mod.rs:1300](../../src-tauri/src/commands/download/mod.rs#L1300)) drops the caller's grant and unlinks the file **only when the last grant goes**. It currently deletes unconditionally, which under sharing would yank a file from under another profile. - Budget is naturally shared: the file is counted once. Eviction picks files with no recent access across *any* grant. - A grant is not an entitlement. On unlock while online, grants for items the profile can no longer see are dropped, alongside the visibility re-derivation. ### Device ID [device_get_id](../../src-tauri/src/commands/device.rs#L27) mints one UUID per installation, sent as `DeviceId` on every request ([client.rs:60](../../src-tauri/src/jellyfin/client.rs#L60)). Jellyfin uses it to identify a *session* — the Dashboard → Devices row, and the target the remote-control feature casts to. **Decision pending an empirical test** (see Open questions). Shipping default is a per-profile derived ID, `uuid5(device_uuid, user_id)`, which makes each family member a distinct device entry so playback history attributes cleanly and the sessions list can tell "this TV, Dad" from "this TV, Kid". If the test shows Jellyfin tolerates a shared ID *and* the merged view is preferred, one line changes. ### Switch orchestration `profiles_switch` is **not** `auth_logout`. Logout invalidates the token server-side; a switch must leave the outgoing profile able to come back with one tap. Ordering, in Rust, as a state machine over a `ProfileSession` so it is unit-testable without a player or a server: 1. Pause playback and tear down the queue (the queue cannot outlive its owner — a straggler would report the outgoing profile's episode against the incoming one). 2. Drain or park `sync_queue` for the outgoing user. 3. Stop the session poller; unregister MPRIS / MediaSession metadata. 4. Destroy the repository handle. 5. Flip `users.is_active`. 6. Build the new repository, restart the poller, re-derive visibility and grants if online. 7. Emit `profile-switched`. Two hazards, both already documented in CLAUDE.md and both reached from a new direction here: never call blocking APIs from player event callbacks, and never hold a lock across a `match` scrutinee. Teardown touches every one of those paths at once. ### Lock state vs. playback "Locked" and "who is the active profile" are **different state**. Re-lock (idle timeout, off by default) flips only the first: - Audio keeps playing and keeps reporting as the profile that started it. - The lockscreen / MediaSession keeps full transport control over the **existing queue** — play, pause, seek, next, prev. Nothing on the lockscreen browses or starts new content, so [MediaSessionCompat](../architecture/05-platform-backends.md) needs no changes at all. - The locked UI refuses anything reaching past the current queue: browsing, search, new playback, downloads, settings, switching profile without the PIN. Two rules keep it coherent: - **Never re-lock while something is playing.** The idle timer starts when playback stops, not when the UI goes quiet. This removes almost all of the conflict on its own. - **Unlocking to a *different* profile stops playback.** Unlocking to the same profile leaves everything running. The timer lives in Rust beside the player state machine: it needs authoritative playback state, and a frontend timer dies with the webview on Android. ### Startup The picker appears only when the last-used profile has a PIN, **or** more than one profile exists and "ask who's watching" is on. Otherwise startup resumes the last account exactly as [auth_initialize](../../src-tauri/src/commands/auth.rs#L19) does today. One account, no PIN → the user never sees any of this. ### Commands Names match the Rust fns; top-level params auto-convert to camelCase. ```rust profiles_list() -> Vec profiles_startup_target() -> StartupTarget // Resume{user_id} | Picker profiles_unlock(user_id: String, pin: Option) -> UnlockOutcome profiles_unlock_with_password(user_id: String, password: String) -> UnlockOutcome profiles_add(username: String, password: String, pin: Option) -> Profile profiles_set_pin(user_id: String, current_pin: Option, new_pin: Option) profiles_remove(user_id: String, forget_downloads: bool) ``` ```rust #[derive(Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct Profile { pub user_id: String, pub username: String, pub avatar_tag: Option, pub unlock_method: UnlockMethod, pub last_used_at: Option, pub is_active: bool, } #[derive(Serialize, Type)] #[serde(rename_all = "camelCase")] pub enum UnlockMethod { None, Pin } #[derive(Serialize, Type)] #[serde(tag = "type", rename_all = "camelCase")] pub enum UnlockOutcome { Ok { user_id: String }, WrongPin { attempts_remaining: u32 }, LockedOut { until: String }, NeedsPassword, } ``` Event: `profile-switched` (kebab-case), payload `{ userId }`. `profiles_add` takes no server URL — it authenticates against the *current* server. That is the same-server constraint, enforced in Rust rather than by omitting a form field. ## Out of scope - Multiple servers. The schema already supports it (`users.server_id`); only the flow is constrained. Not a schema change to undo later. - Per-profile content restriction. That is Jellyfin's, server-side. - Profile avatars uploaded locally — use the server's `avatar_tag`. - Biometric unlock. - Idle re-lock is specified above but ships **off by default** and last. ## Acceptance criteria - [ ] One account with no PIN: startup, playback and downloads are byte-identical to today. - [ ] A second profile can be added with a password, against the current server only. - [ ] A PIN-less profile switches in one tap; a PIN profile requires the PIN. - [ ] Wrong PIN decrements attempts, then locks out with a stated window; the counter survives an app restart. - [ ] "Use password instead" signs in and offers to set a new PIN. - [ ] Switching does **not** invalidate the outgoing profile's token — switching back needs no password. - [ ] A child profile does not see cached items or downloads belonging to another profile, online or offline. - [ ] Two profiles requesting the same item produce one file and two grants; removing one grant keeps the file. - [ ] Upgrading an existing install shows the same library it showed before (backfill works). - [ ] Playback survives an idle re-lock; lockscreen transport still works; unlocking to a different profile stops it. - [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass. - [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes. - [ ] `bun run check:boundary` and the new `check:cache-scope` pass. - [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes. - [ ] `bindings.ts` regenerated. ## Testing **Rust** - PIN: correct/incorrect/lockout/expiry-of-lockout; counter persists across a service restart; a cleared PIN removes the row. - Switch orchestration as a pure state machine over `ProfileSession` — assert the teardown *ordering*, no player or server needed. This is the part that would otherwise only ever be hand-tested. - Visibility: `save_to_cache` stamps; a second user's read of the same item returns nothing; migration backfill populates the existing user. - Grants: second grant transfers no bytes; cancel with two grants keeps the file; cancel of the last grant unlinks it. - Same-server: `profiles_add` against a different URL is rejected. **Frontend** - Picker renders from `profiles_list` with no unlock-method inference of its own. - PIN pad calls `profiles_unlock` and renders each `UnlockOutcome` variant; it never compares a PIN or counts an attempt locally. - `tauriIntegration.test.ts` gains the new commands (camelCase param guard). **Manual — the honest gap.** End-to-end multi-user needs two real accounts with differing library permissions on a real server; CI has neither. The state-machine extraction above is what keeps the risky half testable. The rest is a documented manual pass in the release checklist. ## TRACES | Piece | Tag | |-------|-----| | `profiles_*` commands | `UR-082 \| DR-267` | | PIN hash + lockout | `UR-083 \| DR-268` | | Password fallback | `UR-084 \| DR-269` | | Switch orchestration | `UR-082 \| DR-270` | | Visibility stamp/filter | `UR-082 \| DR-271` | | Download grants | `UR-082 \| IR-034, DR-272` | | Per-profile device ID | `UR-082 \| DR-273` | | Startup target | `UR-082 \| DR-274` | | Idle re-lock | `UR-083 \| DR-275` | | Picker + PIN pad UI | `UR-082, UR-083 \| DR-276` | ## Open questions 1. **Does authenticating a second user with an in-use `DeviceId` invalidate the first user's token?** Two minutes with two accounts: log in as A, log in as B with the same DeviceId, then call `/Sessions` with A's token. Decides whether the per-profile device ID is a preference or a requirement. 2. Should removing a profile default to deleting its exclusive downloads, or keeping them? Spec currently makes it an explicit flag. ## Notes for the implementer - A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes. - `auth_logout` stays exactly as it is. Do not refactor switching through it; the server-side invalidation is the whole reason it is unsuitable. - The docs say the keyring key is `jellytau::{server_id}::{user_id}::access_token`; [credentials.rs:244](../../src-tauri/src/credentials.rs#L244) actually writes `access_token:{user_id}`. Fix the doc, not the code — changing the key format would strand every existing token.