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
+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);
}
}