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
+48
View File
@@ -176,6 +176,18 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "ascii"
version = "1.1.0"
@@ -360,6 +372,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -390,6 +408,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -913,6 +940,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -2184,6 +2212,7 @@ name = "jellytau"
version = "0.11.5"
dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"base64 0.22.1",
"chrono",
@@ -2200,6 +2229,7 @@ dependencies = [
"libmpv-sys",
"log",
"ndk-context",
"password-hash",
"rand 0.8.7",
"reqwest 0.12.28",
"rusqlite",
@@ -3065,6 +3095,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -4284,6 +4325,12 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -5593,6 +5640,7 @@ dependencies = [
"getrandom 0.3.4",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
+7 -1
View File
@@ -43,7 +43,7 @@ tauri-plugin-opener = "2"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
uuid = { version = "1", features = ["v4", "v5"] }
rand = "0.8"
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
tokio-util = "0.7"
@@ -64,6 +64,12 @@ aes-gcm = "0.10"
base64 = "0.22"
sha2 = "0.10"
getrandom = "0.2"
# Profile PIN hashing (DR-268). A switching gate against a member of the
# household, not at-rest protection -- but a hash is the right primitive for a
# gate, and Argon2id costs nothing extra over a weaker one.
argon2 = "0.5"
password-hash = { version = "0.5", features = ["alloc", "rand_core"] }
log = "0.4"
env_logger = "0.11"
+33
View File
@@ -30,6 +30,39 @@ pub async fn auth_initialize(
// Try to restore session from storage
log::info!("[AuthManager] Restoring session from storage...");
// A PIN-protected profile is not restored automatically. Restoring it would
// hand the app a working token before anybody entered the code, leaving the
// picker as decoration over a session that was already live — the gate has
// to be on the session itself, not on which screen is shown. The frontend
// sees `None`, asks `profiles_startup_target`, and lands on the picker.
//
// TRACES: UR-083 | DR-268, DR-274
{
let db_service = {
let db = database.0.lock().map_err(|e| e.to_string())?;
std::sync::Arc::new(db.service())
};
let locked: Option<String> = crate::storage::db_service::DatabaseService::query_optional(
&*db_service,
crate::storage::db_service::Query::new(
"SELECT u.id FROM users u
JOIN user_pins p ON p.user_id = u.id
WHERE u.is_active = 1",
),
|row| row.get(0),
)
.await
.unwrap_or(None);
if let Some(user_id) = locked {
log::info!(
"[AuthManager] Active profile {} is PIN-protected; not restoring its session",
user_id
);
return Ok(None);
}
}
// Use the existing storage_get_active_session function
let active_session =
match crate::commands::storage::storage_get_active_session(database, credentials).await {
+2
View File
@@ -15,6 +15,7 @@ pub mod playback_mode;
pub mod playback_reporting;
pub mod player;
pub mod playlist;
pub mod profiles;
pub mod repository;
pub mod sessions;
pub mod storage;
@@ -35,6 +36,7 @@ pub use playback_mode::*;
pub use playback_reporting::*;
pub use player::*;
pub use playlist::*;
pub use profiles::*;
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
pub use sessions::*;
pub use storage::*;
+544
View File
@@ -0,0 +1,544 @@
//! Profile commands: who can use this device, and how they get in.
//!
//! The rule that shapes this whole module: **switching is not logging out**.
//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
//! which invalidates the token server-side — so a switch built on it would make
//! every switch back cost a password, which is the problem this feature exists
//! to solve. Nothing here calls it.
//!
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
use std::sync::Arc;
use log::{info, warn};
use tauri::{Emitter, State};
use crate::commands::sessions::SessionPollerWrapper;
use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
use crate::profiles::pin::{self, PinDecision, PinState};
use crate::profiles::switch::{plan, SwitchStep};
use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// Settings key for "ask who's watching on start".
const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
let database = db.0.lock().map_err(|e| e.to_string())?;
Ok(Arc::new(database.service()))
}
/// The server this device is signed in to, as `(server_id, server_url)`.
///
/// Every profile operation is scoped to it — this is where the same-server
/// constraint is actually enforced, rather than by omitting a URL field from a
/// form.
///
/// The fallback to the `servers` table is not a convenience. When the last-used
/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
/// session, so at startup there is no in-memory session to read — and the picker
/// still has to know which server's profiles to list. Reading it from storage is
/// what lets the PIN gate a real thing rather than just a screen.
async fn current_server(
db: &Arc<RusqliteService>,
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
) -> Result<(String, String), String> {
if let Some(session) = auth_manager.0.get_session().await {
return Ok((session.server_id, session.server_url));
}
db.query_optional(
Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "No server connected".to_string())
}
async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
let query = Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
);
db.query_optional(query, |row| row.get::<_, String>(0))
.await
.ok()
.flatten()
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// List the accounts this device knows for the current server.
///
/// TRACES: UR-082 | DR-267
#[tauri::command]
#[specta::specta]
pub async fn profiles_list(
db: State<'_, DatabaseWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
) -> Result<Vec<Profile>, String> {
let svc = service(&db)?;
let (server_id, _) = current_server(&svc, &auth_manager).await?;
store::list_profiles(&svc, &server_id).await
}
/// Whether startup should resume an account or ask who is watching.
///
/// The decision is backend state, so the frontend asks rather than computes it.
///
/// TRACES: UR-082 | DR-274
#[tauri::command]
#[specta::specta]
pub async fn profiles_startup_target(
db: State<'_, DatabaseWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
) -> Result<StartupTarget, String> {
let svc = service(&db)?;
let (server_id, _) = match current_server(&svc, &auth_manager).await {
Ok(pair) => pair,
// Nothing signed in yet: the picker doubles as first-run login.
Err(_) => return Ok(StartupTarget::Picker),
};
let profiles = store::list_profiles(&svc, &server_id).await?;
Ok(startup_target(&profiles, ask_on_start(&svc).await))
}
/// Read the "ask who's watching on start" setting.
///
/// Separate from [`profiles_startup_target`] on purpose: the target can be
/// `Picker` for reasons that have nothing to do with this setting — a
/// PIN-protected last profile always asks — so deriving the toggle's position
/// from it would show the user a switch that does not describe what it controls.
///
/// TRACES: UR-082 | DR-274
#[tauri::command]
#[specta::specta]
pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
let svc = service(&db)?;
Ok(ask_on_start(&svc).await)
}
/// Turn "ask who's watching on start" on or off.
///
/// TRACES: UR-082 | DR-274
#[tauri::command]
#[specta::specta]
pub async fn profiles_set_ask_on_start(
db: State<'_, DatabaseWrapper>,
enabled: bool,
) -> Result<(), String> {
let svc = service(&db)?;
let query = Query::with_params(
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(ASK_ON_START_KEY.to_string()),
QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
],
);
svc.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Enter a profile, with its PIN if it has one.
///
/// A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
/// invent a lock the backend does not have, and cannot skip one it does.
///
/// TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn profiles_unlock(
app: tauri::AppHandle,
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: State<'_, SessionPollerWrapper>,
user_id: String,
pin_code: Option<String>,
) -> Result<UnlockOutcome, String> {
let svc = service(&db)?;
let profile = store::get_profile(&svc, &user_id)
.await?
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
// Same-server constraint, checked at the point of use rather than trusted
// from the caller.
let (server_id, _) = current_server(&svc, &auth_manager).await?;
if profile.server_id != server_id {
return Err("Profile belongs to a different server".to_string());
}
if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
let candidate = pin_code.unwrap_or_default();
let matches = pin::verify_pin(&candidate, &hash);
let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
store::save_pin_state(&svc, &user_id, &next_state).await?;
match decision {
PinDecision::Reject { attempts_remaining } => {
return Ok(UnlockOutcome::WrongPin { attempts_remaining })
}
PinDecision::Locked { until } => {
return Ok(UnlockOutcome::LockedOut {
until: until.to_rfc3339(),
})
}
PinDecision::Accept => {}
}
}
let outgoing = active_user_id(&svc).await;
execute_switch(
&app,
&svc,
&repository_manager,
&session_poller,
outgoing.as_deref(),
&user_id,
)
.await?;
adopt_session(db, creds, &auth_manager).await?;
Ok(UnlockOutcome::Ok { user_id })
}
/// Enter a profile with its Jellyfin password, for someone who has forgotten
/// their PIN.
///
/// There is deliberately no reset token and no recovery secret: the account's
/// own password is already the authority over it, and a second credential
/// guarding the same thing would only be a weaker one. A successful password
/// entry also clears the lockout, which is what makes a forgotten PIN a
/// detour rather than a dead end.
///
/// TRACES: UR-084 | DR-269
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn profiles_unlock_with_password(
app: tauri::AppHandle,
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: State<'_, SessionPollerWrapper>,
user_id: String,
password: String,
device_id: String,
) -> Result<UnlockOutcome, String> {
let svc = service(&db)?;
let profile = store::get_profile(&svc, &user_id)
.await?
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
if profile.server_id != server_id {
return Err("Profile belongs to a different server".to_string());
}
let result = auth_manager
.0
.login(&server_url, &profile.username, &password, &device_id)
.await?;
if result.user.id != user_id {
return Err("Signed in as a different account".to_string());
}
save_token(&creds, &user_id, &result.access_token)?;
// The password got in, so the PIN counters have served their purpose.
store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
let outgoing = active_user_id(&svc).await;
execute_switch(
&app,
&svc,
&repository_manager,
&session_poller,
outgoing.as_deref(),
&user_id,
)
.await?;
adopt_session(db, creds, &auth_manager).await?;
Ok(UnlockOutcome::Ok { user_id })
}
/// Add another account from the **current** server to this device.
///
/// Takes no server URL. That is the same-server constraint expressed as a
/// signature rather than as form validation: there is no way to ask this command
/// for an account somewhere else.
///
/// TRACES: UR-082 | DR-267
#[tauri::command]
#[specta::specta]
pub async fn profiles_add(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
username: String,
password: String,
pin_code: Option<String>,
device_id: String,
) -> Result<Profile, String> {
if let Some(code) = &pin_code {
pin::validate_pin(code)?;
}
let svc = service(&db)?;
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
let result = auth_manager
.0
.login(&server_url, &username, &password, &device_id)
.await?;
let insert = Query::with_params(
"INSERT INTO users (id, server_id, username, last_login_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
server_id = excluded.server_id,
username = excluded.username,
last_login_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(result.user.id.clone()),
QueryParam::String(server_id.clone()),
QueryParam::String(result.user.name.clone()),
],
);
svc.execute(insert).await.map_err(|e| e.to_string())?;
save_token(&creds, &result.user.id, &result.access_token)?;
if let Some(code) = pin_code {
let hash = pin::hash_pin(&code)?;
store::set_pin(&svc, &result.user.id, &hash).await?;
}
info!("[Profiles] Added profile {}", result.user.name);
store::get_profile(&svc, &result.user.id)
.await?
.ok_or_else(|| "Profile vanished after being added".to_string())
}
/// Set, change, or clear a profile's PIN.
///
/// Changing an existing PIN requires the current one. Clearing it (`new_pin =
/// None`) does too — otherwise the lock could be removed by whoever is standing
/// in front of the unlocked device, which is exactly who it exists to stop.
///
/// TRACES: UR-083 | DR-268
#[tauri::command]
#[specta::specta]
pub async fn profiles_set_pin(
db: State<'_, DatabaseWrapper>,
user_id: String,
current_pin: Option<String>,
new_pin: Option<String>,
) -> Result<(), String> {
let svc = service(&db)?;
if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
let provided = current_pin.unwrap_or_default();
if !pin::verify_pin(&provided, &hash) {
return Err("Current PIN is incorrect".to_string());
}
}
match new_pin {
Some(code) => {
pin::validate_pin(&code)?;
let hash = pin::hash_pin(&code)?;
store::set_pin(&svc, &user_id, &hash).await
}
None => store::clear_pin(&svc, &user_id).await,
}
}
/// Forget a profile on this device.
///
/// Does not call Jellyfin's logout endpoint: removing an account from the family
/// TV should not sign that person out on their phone. The stored token is
/// deleted locally, which is the part that actually belongs to this device.
///
/// TRACES: UR-082 | DR-267
#[tauri::command]
#[specta::specta]
pub async fn profiles_remove(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
user_id: String,
) -> Result<(), String> {
let svc = service(&db)?;
if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
return Err("Switch to another profile before removing this one".to_string());
}
{
let store = creds.0.lock().map_err(|e| e.to_string())?;
if let Err(e) = store.delete_token(&user_id) {
warn!("[Profiles] Could not delete stored token: {}", e);
}
}
store::remove_profile(&svc, &user_id).await
}
// --- internals ---------------------------------------------------------------
fn save_token(
creds: &State<'_, CredentialStoreWrapper>,
user_id: &str,
token: &str,
) -> Result<(), String> {
let store = creds.0.lock().map_err(|e| e.to_string())?;
store
.save_token(user_id, token)
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
db.query_optional(
Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
|row| row.get::<_, String>(0),
)
.await
.ok()
.flatten()
}
/// Run a switch plan.
///
/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
/// written out here, because the ordering is the invariant worth testing and an
/// end-to-end switch needs two real accounts on a real server to exercise.
///
/// One step is deliberately not executed here: `BuildRepository`. Repository
/// handles are created by the frontend (`repository_create`) because building
/// one needs the token and URL it already assembles at login, so the
/// `profile-switched` event is the signal to do it. What stays in Rust is the
/// part that matters — that the old handle is destroyed *before* the active user
/// flips, so nothing can write under the wrong id in between.
///
/// TRACES: UR-082 | DR-270
async fn execute_switch(
app: &tauri::AppHandle,
db: &Arc<RusqliteService>,
repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: &State<'_, SessionPollerWrapper>,
from: Option<&str>,
to: &str,
) -> Result<(), String> {
let online = true;
let steps = plan(from, to, online);
for step in steps {
match step {
SwitchStep::StopPlayback => {
// The queue cannot outlive its owner: a report landing after the
// flip would attribute one account's viewing to another.
if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
warn!("[Profiles] Could not signal playback stop: {}", e);
}
}
SwitchStep::ParkSyncQueue { user_id } => {
// Rows stay queued under their own user id; nothing is dropped.
// Parking is simply declining to drain them under a different
// token, which the drain already keys on.
info!("[Profiles] Parking sync queue for {}", user_id);
}
SwitchStep::StopSessionPoller => session_poller.0.stop(),
SwitchStep::ClearLockscreenMetadata => {
if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
}
}
SwitchStep::DestroyRepository => {
let manager = &repository_manager.0;
for handle in manager.handles() {
manager.destroy(&handle);
}
}
SwitchStep::SetActiveUser { user_id } => {
set_active_user(db, &user_id).await?;
}
SwitchStep::BuildRepository { .. } => {
// Owned by the frontend; see the doc comment above.
}
SwitchStep::StartSessionPoller => {
// The poller restarts with the new session once the frontend has
// built its repository, for the same reason.
}
SwitchStep::RefreshVisibility { user_id } => {
info!("[Profiles] Visibility refresh queued for {}", user_id);
}
SwitchStep::EmitSwitched { user_id } => {
app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
/// Make the newly-active profile the session the rest of the backend acts as.
///
/// The token lives in the credential store, which the frontend cannot read, so
/// the swap has to happen here — the frontend then rebuilds its repository
/// handle from the session it can now read back. This mirrors what
/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
/// deliberately reuses the same storage path rather than a second one that could
/// drift from it.
///
/// TRACES: UR-082 | DR-270
async fn adopt_session(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
) -> Result<(), String> {
let active = super::storage::storage_get_active_session(db, creds)
.await?
.ok_or_else(|| "Profile has no stored session".to_string())?;
let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
auth_manager
.0
.set_session(Some(crate::auth::Session {
user_id: active.user_id,
username: active.username,
server_id: active.server_id,
server_url: normalized_url,
server_name: active.server_name,
access_token: active.access_token,
verified: false,
needs_reauth: false,
}))
.await;
Ok(())
}
async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
db.execute(Query::new("UPDATE users SET is_active = 0"))
.await
.map_err(|e| e.to_string())?;
db.execute(Query::with_params(
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::String(user_id.to_string())],
))
.await
.map_err(|e| e.to_string())?;
Ok(())
}
+19
View File
@@ -15,6 +15,7 @@ mod media_server;
mod playback_mode;
mod playback_reporting;
mod player;
mod profiles;
mod repository;
mod session_poller;
pub mod settings;
@@ -194,6 +195,15 @@ use commands::{
playlist_move_item,
playlist_remove_items,
playlist_rename,
profiles_add,
profiles_get_ask_on_start,
profiles_list,
profiles_remove,
profiles_set_ask_on_start,
profiles_set_pin,
profiles_startup_target,
profiles_unlock,
profiles_unlock_with_password,
// Remote session control commands
remote_play_on_session,
remote_send_command,
@@ -1036,6 +1046,15 @@ fn specta_builder() -> Builder<tauri::Wry> {
playlist_rename,
playlist_get_items,
playlist_add_items,
profiles_add,
profiles_get_ask_on_start,
profiles_list,
profiles_remove,
profiles_set_ask_on_start,
profiles_set_pin,
profiles_startup_target,
profiles_unlock,
profiles_unlock_with_password,
playlist_remove_items,
playlist_move_item,
// Diagnostics commands
+197
View File
@@ -0,0 +1,197 @@
//! Multi-user profiles on one device.
//!
//! A "profile" is an account on the *currently connected* Jellyfin server that
//! this device has signed into at least once. The rows have existed since the
//! first schema (`users`), and so have `storage_get_users` /
//! `storage_set_active_user`; what was missing was never the storage but the
//! decision of who may switch to what — which is domain logic, and stays here.
//!
//! Two things this module is careful about:
//!
//! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout
//! endpoint, which invalidates the token server-side. That is precisely the
//! behaviour a switch must not have, or every switch back would need a
//! password. Nothing here calls it.
//! - **"Child account" is not modelled.** A child's profile is simply one with
//! no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it
//! never infers a role, and no role taxonomy is invented on either side.
//!
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268
pub mod pin;
pub mod store;
pub mod switch;
use serde::{Deserialize, Serialize};
/// How a profile is entered.
///
/// Deliberately not "adult"/"child": the app has no way to know a person's age
/// and no business encoding one. It knows whether a code is set.
///
/// TRACES: UR-083 | DR-276
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UnlockMethod {
/// One tap. No code set.
None,
/// A numeric code gates the switch.
Pin,
}
/// A switchable account on this device.
///
/// TRACES: UR-082 | DR-267
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub user_id: String,
pub username: String,
pub server_id: String,
/// Jellyfin's primary-image tag, for the tile. `None` renders initials.
pub avatar_tag: Option<String>,
pub unlock_method: UnlockMethod,
pub last_used_at: Option<String>,
pub is_active: bool,
}
/// The result of an unlock attempt.
///
/// Note the explicit field renames. tauri-specta emits tagged-union *fields*
/// with their Rust names rather than camelCasing them, so a field that would
/// differ between the two conventions is renamed here by hand — the same trap
/// that produced `new_url` on the frontend once already.
///
/// TRACES: UR-083, UR-084 | DR-268, DR-269
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum UnlockOutcome {
/// Switched. The profile is now active.
Ok {
#[serde(rename = "userId")]
user_id: String,
},
/// Wrong code, attempts left.
WrongPin {
#[serde(rename = "attemptsRemaining")]
attempts_remaining: u32,
},
/// Too many wrong codes; refused until this RFC3339 instant.
LockedOut { until: String },
/// No code is recoverable from here — sign in with the account password.
NeedsPassword,
}
/// What the app should do when it starts.
///
/// The decision is backend state (profile count, PIN presence, a stored
/// setting), so the frontend asks rather than computes. A single account with no
/// PIN always resumes, which is what keeps this feature invisible until it is
/// wanted.
///
/// TRACES: UR-082 | DR-274
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum StartupTarget {
/// Resume this profile without asking.
Resume {
#[serde(rename = "userId")]
user_id: String,
},
/// Show the picker.
Picker,
}
/// Decide the startup target from the profiles present and the user's setting.
///
/// Pure, because the rule is worth testing and the inputs are trivial to state:
///
/// - No profiles at all → picker (which renders as the first-run login).
/// - The last-used profile has a PIN → picker, regardless of the setting. A code
/// that could be skipped by relaunching is not a code.
/// - More than one profile and "ask who's watching" is on → picker.
/// - Otherwise → resume, exactly as the app behaved before profiles existed.
///
/// TRACES: UR-082 | DR-274
pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget {
let last_used = profiles
.iter()
.max_by(|a, b| a.last_used_at.cmp(&b.last_used_at));
match last_used {
None => StartupTarget::Picker,
Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker,
Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker,
Some(p) => StartupTarget::Resume {
user_id: p.user_id.clone(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile {
Profile {
user_id: id.to_string(),
username: id.to_string(),
server_id: "server-1".to_string(),
avatar_tag: None,
unlock_method: unlock,
last_used_at: last_used.map(|s| s.to_string()),
is_active: false,
}
}
/// UT: the pre-profiles install — one account, no PIN — never sees a picker.
#[test]
fn single_pinless_profile_resumes() {
let profiles = vec![profile(
"u1",
UnlockMethod::None,
Some("2026-01-01T00:00:00Z"),
)];
assert_eq!(
startup_target(&profiles, true),
StartupTarget::Resume {
user_id: "u1".to_string()
},
"a lone profile resumes even with the setting on"
);
}
/// UT: a PIN is not skippable by relaunching the app.
#[test]
fn pinned_last_profile_always_asks() {
let profiles = vec![profile(
"u1",
UnlockMethod::Pin,
Some("2026-01-01T00:00:00Z"),
)];
assert_eq!(startup_target(&profiles, false), StartupTarget::Picker);
}
/// UT: several pinless profiles resume the last one unless asked to ask.
#[test]
fn multiple_profiles_follow_the_setting() {
let profiles = vec![
profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")),
profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")),
];
assert_eq!(
startup_target(&profiles, false),
StartupTarget::Resume {
user_id: "u2".to_string()
},
"resumes the most recently used"
);
assert_eq!(startup_target(&profiles, true), StartupTarget::Picker);
}
/// UT: nothing signed in yet.
#[test]
fn no_profiles_shows_the_picker() {
assert_eq!(startup_target(&[], false), StartupTarget::Picker);
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Profile PIN: hashing, and the lockout policy that decides what a guess costs.
//!
//! The policy half is deliberately pure — it takes the stored counter state and
//! the current time, and returns the decision plus the next state. That is what
//! makes "five wrong guesses then a lockout that survives a restart" testable
//! without a database, a clock, or a running app.
//!
//! What this is *not*: at-rest protection. The PIN gates switching to a profile;
//! it does not encrypt that profile's access token, so anyone holding the
//! database and the keyring has every token regardless. That trade is deliberate
//! and its reasoning lives in DR-268 — a wrapped token would leave a locked
//! profile unable to resume its own downloads or drain its own sync queue until
//! somebody walked past and typed the code.
//!
//! TRACES: UR-083 | DR-268
use argon2::Argon2;
use chrono::{DateTime, Duration, Utc};
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
/// Wrong guesses allowed before the first lockout.
pub const MAX_ATTEMPTS: u32 = 5;
/// How long the first lockout lasts. Each subsequent failure doubles it.
const BASE_LOCKOUT_SECS: i64 = 60;
/// Ceiling on the doubling, so a forgotten PIN never bricks the tile — the
/// password route is always there, and a lockout measured in hours would push
/// people towards not setting a PIN at all.
const MAX_LOCKOUT_SECS: i64 = 15 * 60;
/// Persisted counter state for one profile's PIN.
///
/// TRACES: UR-083 | DR-268
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinState {
pub failed_count: u32,
pub locked_until: Option<DateTime<Utc>>,
}
impl PinState {
pub fn fresh() -> Self {
Self {
failed_count: 0,
locked_until: None,
}
}
}
/// What the caller should do with an attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinDecision {
Accept,
Reject { attempts_remaining: u32 },
Locked { until: DateTime<Utc> },
}
/// Decide an attempt and produce the state to persist.
///
/// `pin_matches` is the result of the hash comparison; passing it in rather than
/// doing the comparison here is what keeps this function pure and cheap to test
/// across the whole attempt/lockout space.
///
/// A locked profile is refused *without consulting the hash*, so a caller cannot
/// burn through a lockout by guessing quickly.
///
/// TRACES: UR-083 | DR-268
pub fn evaluate(
state: &PinState,
now: DateTime<Utc>,
pin_matches: bool,
) -> (PinDecision, PinState) {
if let Some(until) = state.locked_until {
if now < until {
return (PinDecision::Locked { until }, state.clone());
}
}
if pin_matches {
return (PinDecision::Accept, PinState::fresh());
}
let failed_count = state.failed_count.saturating_add(1);
if failed_count >= MAX_ATTEMPTS {
let over = i64::from(failed_count - MAX_ATTEMPTS);
let secs = BASE_LOCKOUT_SECS
.saturating_mul(1i64.checked_shl(over.min(16) as u32).unwrap_or(i64::MAX))
.min(MAX_LOCKOUT_SECS);
let until = now + Duration::seconds(secs);
(
PinDecision::Locked { until },
PinState {
failed_count,
locked_until: Some(until),
},
)
} else {
(
PinDecision::Reject {
attempts_remaining: MAX_ATTEMPTS - failed_count,
},
PinState {
failed_count,
locked_until: None,
},
)
}
}
/// A PIN must be 48 digits. Rejecting non-digits here rather than in the pad
/// keeps the rule where the rule is enforced.
///
/// TRACES: UR-083 | DR-268
pub fn validate_pin(pin: &str) -> Result<(), String> {
if pin.len() < 4 || pin.len() > 8 {
return Err("PIN must be between 4 and 8 digits".to_string());
}
if !pin.chars().all(|c| c.is_ascii_digit()) {
return Err("PIN must contain only digits".to_string());
}
Ok(())
}
/// Hash a PIN for storage. Returns a PHC string with the salt embedded.
///
/// TRACES: UR-083 | DR-268
pub fn hash_pin(pin: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(pin.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| format!("Failed to hash PIN: {}", e))
}
/// Compare a candidate PIN against a stored PHC string.
///
/// A malformed stored hash verifies as `false` rather than erroring: a corrupt
/// row should send the user down the password route, not wedge the picker.
///
/// TRACES: UR-083 | DR-268
pub fn verify_pin(pin: &str, stored: &str) -> bool {
match PasswordHash::new(stored) {
Ok(parsed) => Argon2::default()
.verify_password(pin.as_bytes(), &parsed)
.is_ok(),
Err(e) => {
log::warn!("[Profiles] Stored PIN hash is unreadable: {}", e);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t0() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
/// UT: a correct PIN is accepted and clears any accumulated failures.
#[test]
fn correct_pin_accepts_and_resets() {
let state = PinState {
failed_count: 3,
locked_until: None,
};
let (decision, next) = evaluate(&state, t0(), true);
assert_eq!(decision, PinDecision::Accept);
assert_eq!(next, PinState::fresh());
}
/// UT: wrong guesses count down, and the count is what gets persisted —
/// this is the half that must survive an app restart.
#[test]
fn wrong_pin_counts_down() {
let mut state = PinState::fresh();
for expected in (1..MAX_ATTEMPTS).rev() {
let (decision, next) = evaluate(&state, t0(), false);
assert_eq!(
decision,
PinDecision::Reject {
attempts_remaining: expected
}
);
state = next;
}
assert_eq!(state.failed_count, MAX_ATTEMPTS - 1);
}
/// UT: the attempt that exhausts the allowance locks out rather than
/// reporting zero attempts remaining.
#[test]
fn exhausting_attempts_locks_out() {
let state = PinState {
failed_count: MAX_ATTEMPTS - 1,
locked_until: None,
};
let (decision, next) = evaluate(&state, t0(), false);
match decision {
PinDecision::Locked { until } => {
assert_eq!(until, t0() + Duration::seconds(BASE_LOCKOUT_SECS));
}
other => panic!("expected lockout, got {:?}", other),
}
assert_eq!(next.locked_until, Some(t0() + Duration::seconds(60)));
}
/// UT: a locked profile is refused without the hash being consulted — the
/// correct PIN does not shortcut an active lockout.
#[test]
fn lockout_refuses_even_a_correct_pin() {
let until = t0() + Duration::seconds(60);
let state = PinState {
failed_count: MAX_ATTEMPTS,
locked_until: Some(until),
};
let (decision, next) = evaluate(&state, t0(), true);
assert_eq!(decision, PinDecision::Locked { until });
assert_eq!(next, state, "a refused attempt must not extend the lockout");
}
/// UT: once the window passes the profile accepts again.
#[test]
fn lockout_expires() {
let until = t0() + Duration::seconds(60);
let state = PinState {
failed_count: MAX_ATTEMPTS,
locked_until: Some(until),
};
let (decision, next) = evaluate(&state, until + Duration::seconds(1), true);
assert_eq!(decision, PinDecision::Accept);
assert_eq!(next, PinState::fresh());
}
/// UT: repeated lockouts escalate, but stop at the ceiling so a forgotten
/// PIN never becomes an hours-long wait.
#[test]
fn lockout_escalates_to_a_ceiling() {
let mut seen = Vec::new();
for failed in MAX_ATTEMPTS - 1..MAX_ATTEMPTS + 12 {
let state = PinState {
failed_count: failed,
locked_until: None,
};
if let (PinDecision::Locked { until }, _) = evaluate(&state, t0(), false) {
seen.push((until - t0()).num_seconds());
}
}
assert_eq!(seen[0], BASE_LOCKOUT_SECS);
assert!(seen[1] > seen[0], "second lockout should be longer");
assert_eq!(*seen.last().unwrap(), MAX_LOCKOUT_SECS);
assert!(seen.windows(2).all(|w| w[1] >= w[0]), "must not shrink");
}
/// UT: hashing round-trips, and a wrong PIN does not verify.
#[test]
fn hash_round_trips() {
let hash = hash_pin("1234").unwrap();
assert!(verify_pin("1234", &hash));
assert!(!verify_pin("4321", &hash));
}
/// UT: the stored hash never contains the PIN itself.
#[test]
fn hash_does_not_leak_the_pin() {
let hash = hash_pin("246813").unwrap();
assert!(!hash.contains("246813"));
}
/// UT: an unreadable stored hash fails closed instead of erroring, so a
/// corrupt row sends the user to the password route.
#[test]
fn corrupt_hash_fails_closed() {
assert!(!verify_pin("1234", "not-a-phc-string"));
}
/// UT: PIN shape is enforced in Rust, not in the pad.
#[test]
fn pin_shape_is_validated() {
assert!(validate_pin("1234").is_ok());
assert!(validate_pin("12345678").is_ok());
assert!(validate_pin("123").is_err(), "too short");
assert!(validate_pin("123456789").is_err(), "too long");
assert!(validate_pin("12a4").is_err(), "non-digit");
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Database access for profiles.
//!
//! Everything here takes an explicit `user_id`. There is no ambient "current
//! user" in this module — the caller has to say who it means, which is what
//! stops a switch half-applying and writing one profile's state under another's
//! id.
//!
//! TRACES: UR-082, UR-083 | DR-267, DR-268
use std::sync::Arc;
use chrono::{DateTime, Utc};
use super::pin::PinState;
use super::{Profile, UnlockMethod};
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// List every profile known for a server, most recently used first.
///
/// A profile's unlock method is derived from the presence of a `user_pins` row
/// rather than stored twice, so the two can never disagree.
///
/// TRACES: UR-082 | DR-267
pub async fn list_profiles(
db: &Arc<RusqliteService>,
server_id: &str,
) -> Result<Vec<Profile>, String> {
let query = Query::with_params(
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
FROM users u
LEFT JOIN user_pins p ON p.user_id = u.id
WHERE u.server_id = ?
ORDER BY u.last_login_at DESC",
vec![QueryParam::String(server_id.to_string())],
);
db.query_many(query, |row| {
let has_pin: i32 = row.get(5)?;
Ok(Profile {
user_id: row.get(0)?,
username: row.get(1)?,
server_id: row.get(2)?,
avatar_tag: None,
unlock_method: if has_pin != 0 {
UnlockMethod::Pin
} else {
UnlockMethod::None
},
last_used_at: row.get(4)?,
is_active: row.get::<_, i32>(3)? != 0,
})
})
.await
.map_err(|e| e.to_string())
}
/// Fetch a single profile, or `None` if this device does not know it.
///
/// TRACES: UR-082 | DR-267
pub async fn get_profile(
db: &Arc<RusqliteService>,
user_id: &str,
) -> Result<Option<Profile>, String> {
let query = Query::with_params(
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
FROM users u
LEFT JOIN user_pins p ON p.user_id = u.id
WHERE u.id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.query_optional(query, |row| {
let has_pin: i32 = row.get(5)?;
Ok(Profile {
user_id: row.get(0)?,
username: row.get(1)?,
server_id: row.get(2)?,
avatar_tag: None,
unlock_method: if has_pin != 0 {
UnlockMethod::Pin
} else {
UnlockMethod::None
},
last_used_at: row.get(4)?,
is_active: row.get::<_, i32>(3)? != 0,
})
})
.await
.map_err(|e| e.to_string())
}
/// The stored PIN hash and attempt counters, or `None` when no PIN is set.
///
/// TRACES: UR-083 | DR-268
pub async fn get_pin(
db: &Arc<RusqliteService>,
user_id: &str,
) -> Result<Option<(String, PinState)>, String> {
let query = Query::with_params(
"SELECT pin_hash, failed_count, locked_until FROM user_pins WHERE user_id = ?",
vec![QueryParam::String(user_id.to_string())],
);
let row: Option<(String, i64, Option<String>)> = db
.query_optional(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.map_err(|e| e.to_string())?;
Ok(row.map(|(hash, failed, locked)| {
let locked_until = locked
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc));
(
hash,
PinState {
failed_count: failed.max(0) as u32,
locked_until,
},
)
}))
}
/// Store (or replace) a profile's PIN, resetting its counters.
///
/// TRACES: UR-083 | DR-268
pub async fn set_pin(
db: &Arc<RusqliteService>,
user_id: &str,
pin_hash: &str,
) -> Result<(), String> {
let query = Query::with_params(
"INSERT INTO user_pins (user_id, pin_hash, failed_count, locked_until, updated_at)
VALUES (?, ?, 0, NULL, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
pin_hash = excluded.pin_hash,
failed_count = 0,
locked_until = NULL,
updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(user_id.to_string()),
QueryParam::String(pin_hash.to_string()),
],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Remove a profile's PIN, making it a one-tap profile.
///
/// TRACES: UR-083 | DR-268
pub async fn clear_pin(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
let query = Query::with_params(
"DELETE FROM user_pins WHERE user_id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Persist the counter state produced by [`super::pin::evaluate`].
///
/// This is what makes a lockout survive a restart: the deadline is on disk, not
/// in a process-lifetime counter that closing the app would clear.
///
/// TRACES: UR-083 | DR-268
pub async fn save_pin_state(
db: &Arc<RusqliteService>,
user_id: &str,
state: &PinState,
) -> Result<(), String> {
let locked = match state.locked_until {
Some(dt) => QueryParam::String(dt.to_rfc3339()),
None => QueryParam::Null,
};
let query = Query::with_params(
"UPDATE user_pins SET failed_count = ?, locked_until = ? WHERE user_id = ?",
vec![
QueryParam::Int64(i64::from(state.failed_count)),
locked,
QueryParam::String(user_id.to_string()),
],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Forget a profile: its PIN, its per-user rows, and its `users` row.
///
/// Deliberately does **not** call Jellyfin's logout endpoint. Removing a profile
/// from this device is a local act; invalidating a token the person may be using
/// on their phone is not what "remove from this TV" means. The caller deletes the
/// stored token separately.
///
/// TRACES: UR-082 | DR-267
pub async fn remove_profile(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
// ON DELETE CASCADE covers user_pins, user_data, user_item_visibility,
// user_libraries, download_grants and the rest; the users row is the root.
let query = Query::with_params(
"DELETE FROM users WHERE id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
+225
View File
@@ -0,0 +1,225 @@
//! Profile switch orchestration, as a plan rather than a procedure.
//!
//! Switching profiles tears down and rebuilds nearly everything the app holds:
//! the player and its queue, the sync queue drain, the session poller, the
//! lockscreen metadata, the repository handle. The *ordering* of that teardown
//! is a correctness invariant, not an implementation detail — a straggler that
//! reports after the active user has flipped attributes one account's viewing to
//! another, which is silent, plausible-looking, and unrecoverable.
//!
//! So the ordering lives here as a pure function returning a list of steps, and
//! the command layer executes them. That is the only way this gets tested: an
//! end-to-end switch needs two real accounts on a real server, which CI does not
//! have and never will. The plan needs nothing.
//!
//! Two hazards worth remembering while executing a plan, both already paid for
//! elsewhere in this codebase (see CLAUDE.md): never call a blocking API from a
//! player event callback, and never hold a lock across a `match` scrutinee. A
//! teardown reaches every one of those paths at once, from a new direction.
//!
//! TRACES: UR-082 | DR-270
/// One executable step of a switch.
///
/// TRACES: UR-082 | DR-270
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SwitchStep {
/// Stop playback and drop the queue. The queue cannot outlive its owner.
StopPlayback,
/// Flush what the outgoing profile changed while offline, so it is not
/// replayed under the incoming profile's token.
ParkSyncQueue {
user_id: String,
},
StopSessionPoller,
/// Clear OS media metadata so the lockscreen does not show the outgoing
/// profile's episode to whoever just took over the device.
ClearLockscreenMetadata,
DestroyRepository,
/// The point of no return: after this, writes land under the new profile.
SetActiveUser {
user_id: String,
},
BuildRepository {
user_id: String,
},
StartSessionPoller,
/// Re-derive what the server currently lets this profile see. Only possible
/// online; offline the cached view stays as it was, which is stale-permissive
/// by design.
RefreshVisibility {
user_id: String,
},
EmitSwitched {
user_id: String,
},
}
/// Build the ordered plan for moving from `from` to `to`.
///
/// Switching to the profile that is already active is not a no-op — it is how an
/// idle re-lock is dismissed — but it must not tear down playback, or unlocking
/// your own screen would stop the music. Only the emit survives.
///
/// TRACES: UR-082 | DR-270
pub fn plan(from: Option<&str>, to: &str, online: bool) -> Vec<SwitchStep> {
if from == Some(to) {
return vec![SwitchStep::EmitSwitched {
user_id: to.to_string(),
}];
}
let mut steps = Vec::new();
if let Some(outgoing) = from {
steps.push(SwitchStep::StopPlayback);
steps.push(SwitchStep::ParkSyncQueue {
user_id: outgoing.to_string(),
});
steps.push(SwitchStep::StopSessionPoller);
steps.push(SwitchStep::ClearLockscreenMetadata);
steps.push(SwitchStep::DestroyRepository);
}
steps.push(SwitchStep::SetActiveUser {
user_id: to.to_string(),
});
steps.push(SwitchStep::BuildRepository {
user_id: to.to_string(),
});
steps.push(SwitchStep::StartSessionPoller);
if online {
steps.push(SwitchStep::RefreshVisibility {
user_id: to.to_string(),
});
}
steps.push(SwitchStep::EmitSwitched {
user_id: to.to_string(),
});
steps
}
#[cfg(test)]
mod tests {
use super::*;
fn index_of(steps: &[SwitchStep], want: &SwitchStep) -> usize {
steps
.iter()
.position(|s| s == want)
.unwrap_or_else(|| panic!("step {:?} missing from plan {:?}", want, steps))
}
/// UT: the invariant that prevents misattributed playback reports —
/// everything belonging to the outgoing profile is torn down *before* the
/// active user flips.
#[test]
fn teardown_precedes_the_flip() {
let steps = plan(Some("dad"), "kid", true);
let flip = index_of(
&steps,
&SwitchStep::SetActiveUser {
user_id: "kid".to_string(),
},
);
assert!(index_of(&steps, &SwitchStep::StopPlayback) < flip);
assert!(
index_of(
&steps,
&SwitchStep::ParkSyncQueue {
user_id: "dad".to_string()
}
) < flip
);
assert!(index_of(&steps, &SwitchStep::StopSessionPoller) < flip);
assert!(index_of(&steps, &SwitchStep::DestroyRepository) < flip);
}
/// UT: the outgoing profile's queued offline mutations are parked under
/// *its* id, never the incoming one's.
#[test]
fn sync_queue_is_parked_for_the_outgoing_profile() {
let steps = plan(Some("dad"), "kid", true);
assert!(steps.contains(&SwitchStep::ParkSyncQueue {
user_id: "dad".to_string()
}));
assert!(!steps.contains(&SwitchStep::ParkSyncQueue {
user_id: "kid".to_string()
}));
}
/// UT: the repository is rebuilt only after the flip, so it cannot be
/// constructed against a user id that is about to change.
#[test]
fn repository_is_rebuilt_after_the_flip() {
let steps = plan(Some("dad"), "kid", true);
let flip = index_of(
&steps,
&SwitchStep::SetActiveUser {
user_id: "kid".to_string(),
},
);
assert!(
index_of(
&steps,
&SwitchStep::BuildRepository {
user_id: "kid".to_string()
}
) > flip
);
}
/// UT: the switch is announced last, so nothing observing the event can
/// catch the app mid-teardown.
#[test]
fn switch_is_announced_last() {
let steps = plan(Some("dad"), "kid", true);
assert_eq!(
steps.last(),
Some(&SwitchStep::EmitSwitched {
user_id: "kid".to_string()
})
);
}
/// UT: first sign-in has nothing to tear down.
#[test]
fn cold_start_only_builds_up() {
let steps = plan(None, "kid", true);
assert!(!steps.contains(&SwitchStep::StopPlayback));
assert!(!steps.contains(&SwitchStep::DestroyRepository));
assert_eq!(
steps.first(),
Some(&SwitchStep::SetActiveUser {
user_id: "kid".to_string()
})
);
}
/// UT: offline, visibility cannot be re-derived — the server is not there to
/// say what this profile may see, and guessing would be worse than stale.
#[test]
fn offline_skips_visibility_refresh() {
let steps = plan(Some("dad"), "kid", false);
assert!(!steps
.iter()
.any(|s| matches!(s, SwitchStep::RefreshVisibility { .. })));
}
/// UT: dismissing an idle re-lock on your own profile must not stop the
/// music you were listening to.
#[test]
fn unlocking_the_same_profile_does_not_disturb_playback() {
let steps = plan(Some("dad"), "dad", true);
assert_eq!(
steps,
vec![SwitchStep::EmitSwitched {
user_id: "dad".to_string()
}]
);
}
}
+248
View File
@@ -28,6 +28,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("021_rebuild_items_fts", MIGRATION_021),
("022_people_fts", MIGRATION_022),
("023_downloads_expiry", MIGRATION_023),
("024_multi_user_profiles", MIGRATION_024),
];
/// Initial schema migration
@@ -819,3 +820,250 @@ ALTER TABLE downloads ADD COLUMN expires_at TEXT;
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
ON downloads(download_source, expires_at);
"#;
/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
///
/// Three tables, one purpose each:
///
/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
/// wrapping the access token, because a wrapped token would leave a locked
/// profile unable to resume its own downloads or drain its own sync queue
/// until someone typed the code. See DR-268 for why that trade was taken.
/// - `user_item_visibility` records what the server has actually shown to each
/// user. It is written as a byproduct of the cache write path, never rebuilt,
/// so it cannot disagree with what the server returned.
/// - `download_grants` separates the bytes from the claim on them, so one file
/// can serve several profiles and is unlinked only when the last claim goes.
///
/// The backfill is not optional. Every existing cache row and download predates
/// the concept of a user; without it an upgrading install's library goes blank.
/// It grants the *active* user only — other pre-existing rows re-populate from
/// the server on next browse, which is strictly safer than handing every
/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
/// install whose single user somehow has `is_active = 0`.
///
/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
const MIGRATION_024: &str = r#"
CREATE TABLE IF NOT EXISTS user_pins (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
pin_hash TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
CREATE TABLE IF NOT EXISTS user_libraries (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id TEXT NOT NULL,
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, library_id)
);
CREATE TABLE IF NOT EXISTS 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)
);
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
-- Backfill: the active user has seen everything already cached on this device.
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
SELECT u.id, i.id
FROM users u CROSS JOIN items i
WHERE u.is_active = 1
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
SELECT u.id, l.id
FROM users u CROSS JOIN libraries l
WHERE u.is_active = 1
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
-- Downloads already record who asked for them, so every existing row becomes
-- exactly one grant held by its original requester.
INSERT OR IGNORE INTO download_grants (user_id, download_id)
SELECT d.user_id, d.id FROM downloads d;
"#;
#[cfg(test)]
mod migration_024_tests {
use super::*;
use rusqlite::{params, Connection};
/// Build a database at the schema version *before* multi-user profiles, so
/// the backfill is exercised against rows that predate it — which is the
/// only state that matters, and the one an in-memory database created from
/// the full migration list can never reproduce.
fn pre_024_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
let upto = MIGRATIONS
.iter()
.position(|(name, _)| *name == "024_multi_user_profiles")
.expect("migration 024 must be registered");
for (_, sql) in &MIGRATIONS[..upto] {
conn.execute_batch(sql).unwrap();
}
conn
}
fn seed(conn: &Connection, active_user: &str) {
conn.execute(
"INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
params![active_user],
)
.unwrap();
conn.execute(
"INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
params![active_user],
)
.unwrap();
}
fn apply_024(conn: &Connection) {
let (_, sql) = MIGRATIONS
.iter()
.find(|(name, _)| *name == "024_multi_user_profiles")
.unwrap();
conn.execute_batch(sql).unwrap();
}
fn count(conn: &Connection, sql: &str) -> i64 {
conn.query_row(sql, [], |r| r.get(0)).unwrap()
}
/// UT: upgrading an existing install does not blank its library. Without the
/// backfill every cached item becomes invisible to the only user there is.
#[test]
fn backfill_keeps_the_existing_library_visible() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
),
1,
"the active user must still see what was already cached"
);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
),
1
);
}
/// UT: an existing download becomes exactly one grant, held by whoever asked
/// for it — the file is not orphaned and is not handed to anyone else.
#[test]
fn backfill_grants_downloads_to_their_requester() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
),
1
);
}
/// UT: a second profile added later starts with an empty view. The cache was
/// filled by someone else's browsing and the server never showed it to them,
/// so inheriting it is the leak this whole table exists to close.
#[test]
fn a_later_profile_inherits_nothing() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
conn.execute(
"INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
[],
)
.unwrap();
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
),
0,
"a profile added after the upgrade must not inherit another's cache"
);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
),
0
);
}
/// UT: an install whose sole user somehow has `is_active = 0` still gets its
/// library back — the fallback arm of the backfill.
#[test]
fn backfill_covers_an_install_with_no_active_flag() {
let conn = pre_024_db();
seed(&conn, "dad");
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
),
1
);
}
/// UT: removing a profile takes its per-user rows with it, so a re-added
/// account starts clean rather than resuming someone's stale view.
#[test]
fn removing_a_profile_cascades_its_rows() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
conn.execute("DELETE FROM users WHERE id = 'dad'", [])
.unwrap();
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
}
}