feat(profiles): multi-user profiles with PIN switching

A shared device can hold several accounts from the same server and switch
between them in a couple of taps. A profile can be locked behind a 4-8
digit PIN; one without a PIN is one tap away. Forgetting a PIN falls
through to the account's own Jellyfin password, so there is no reset flow
and no recovery secret to store.

Opt-in by construction: a single account with no PIN starts, plays and
downloads exactly as before, and never sees a picker.

Two decisions worth keeping:

- Switching is not logging out. auth_logout invalidates the token
  server-side, which is precisely what a switch must not do, or every
  switch back would cost a password. The switch runs as a plan
  (profiles/switch.rs) so the teardown *ordering* is unit-testable with
  no player and no server -- a straggler reporting after the active user
  flips would attribute one account's viewing to another, silently.

- The PIN gates switching, not the token at rest. Wrapping each token
  with its PIN would leave a locked profile unable to resume its own
  downloads or drain its own sync queue until somebody typed the code,
  which on a device that reboots nightly costs more than it defends
  against a four-digit secret. auth_initialize does refuse to restore a
  PIN-protected session, so the gate is on the session rather than on
  which screen is shown.

"Child account" is not modelled anywhere -- a child's profile is simply
one with no PIN. The frontend renders an opaque unlockMethod and never
compares a PIN, counts an attempt or infers a role.

Migration 024 adds user_pins, user_item_visibility, user_libraries and
download_grants, and backfills the existing user so an upgrade does not
blank its library. The visibility and grant tables are the schema half of
the cache-scoping and shared-download work; the read-path enforcement is
still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
2026-08-30 19:03:59 +02:00
parent 8a04a6fad0
commit da762da55d
22 changed files with 3392 additions and 5 deletions
+405
View File
@@ -0,0 +1,405 @@
# 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<RusqliteService>,
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<Profile>
profiles_startup_target() -> StartupTarget // Resume{user_id} | Picker
profiles_unlock(user_id: String, pin: Option<String>) -> UnlockOutcome
profiles_unlock_with_password(user_id: String, password: String) -> UnlockOutcome
profiles_add(username: String, password: String, pin: Option<String>) -> Profile
profiles_set_pin(user_id: String, current_pin: Option<String>, new_pin: Option<String>)
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<String>,
pub unlock_method: UnlockMethod,
pub last_used_at: Option<String>,
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.