From 5e7ce4774b2d1f43a3dba96c1602349288df2ec4 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:45:49 +0200 Subject: [PATCH] chore: remove docs/superpowers from repo and gitignore whitelist --- .gitignore | 2 - .../plans/2026-06-25-auth-foundation.md | 1977 ----------------- .../plans/2026-07-20-playlist-children.md | 220 -- .../2026-06-25-auth-foundation-design.md | 403 ---- 4 files changed, 2602 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-25-auth-foundation.md delete mode 100644 docs/superpowers/plans/2026-07-20-playlist-children.md delete mode 100644 docs/superpowers/specs/2026-06-25-auth-foundation-design.md diff --git a/.gitignore b/.gitignore index eeb041b..478a663 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ !docs/ !docs/LICENSE !docs/README* -!docs/superpowers/ -!docs/superpowers/** !crates !crates/** diff --git a/docs/superpowers/plans/2026-06-25-auth-foundation.md b/docs/superpowers/plans/2026-06-25-auth-foundation.md deleted file mode 100644 index 3f9da52..0000000 --- a/docs/superpowers/plans/2026-06-25-auth-foundation.md +++ /dev/null @@ -1,1977 +0,0 @@ -# Auth Foundation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add cookie-session + API-token authentication, a role bitmask system, a first-run setup wizard, and auth-protected routes to the Archivr server. - -**Architecture:** Auth state lives in a dedicated `archivr-auth.sqlite` file next to the server config, separate from the per-archive store DBs. `AppState` gains an `auth_db_path`. Route handlers call `database::open_auth_db()` to get a connection; a new `AuthUser` Axum extractor validates session cookies and Bearer tokens for every protected handler. - -**Tech Stack:** Rust/Axum, rusqlite (existing), argon2 (Argon2id hashing), rand (token generation), axum-extra (cookie extraction), React/Vite (existing frontend) - -**Spec:** `docs/superpowers/specs/2026-06-25-auth-foundation-design.md` - ---- - -## File Map - -| File | Action | What changes | -|---|---|---| -| `Cargo.toml` | Modify | Add `argon2`, `rand`, `axum-extra` to workspace deps | -| `crates/archivr-server/Cargo.toml` | Modify | Pull `argon2`, `rand`, `axum-extra` from workspace | -| `crates/archivr-core/src/database.rs` | Modify | `initialize_auth_schema`, auth CRUD helpers, new record types | -| `crates/archivr-server/src/auth.rs` | **Create** | `AuthUser` extractor, password helpers, token generation, role constants | -| `crates/archivr-server/src/routes.rs` | Modify | `AppState` + `app()` gain `auth_db_path`; auth endpoints; route protection; `ApiError` gets JSON body + `unauthorized`/`forbidden` constructors | -| `crates/archivr-server/src/registry.rs` | Modify | `ServerRegistry` gains optional `auth_db_path` | -| `crates/archivr-server/src/main.rs` | Modify | Compute auth DB path; pass to `app()`; session cleanup task; remove non-loopback auth warning | -| `frontend/src/App.jsx` | Modify | Setup check on mount; `AuthContext`; 401 handling | -| `frontend/src/api.js` | Modify | 401 interceptor; auth helper calls | -| `frontend/src/components/LoginPage.jsx` | **Create** | Login form | -| `frontend/src/components/SetupPage.jsx` | **Create** | First-run owner creation wizard | -| `frontend/src/components/Topbar.jsx` | Modify | User menu + logout button | - ---- - -## Task 1: Add dependencies - -**Files:** -- Modify: `Cargo.toml` -- Modify: `crates/archivr-server/Cargo.toml` - -- [ ] **Step 1: Add workspace dependencies** - -Open `Cargo.toml`. In `[workspace.dependencies]`, add after the `base64` line: - -```toml -argon2 = { version = "0.5", features = ["std"] } -rand = { version = "0.8", features = ["std"] } -axum-extra = { version = "0.9", features = ["cookie"] } -``` - -- [ ] **Step 2: Pull into server crate** - -Open `crates/archivr-server/Cargo.toml`. Add to `[dependencies]`: - -```toml -argon2.workspace = true -rand.workspace = true -axum-extra.workspace = true -``` - -- [ ] **Step 3: Verify compilation** - -```bash -cd ~/personal/archivr && cargo check -p archivr-server -``` - -Expected: compiles with no errors. - -- [ ] **Step 4: Commit** - -```bash -git add Cargo.toml crates/archivr-server/Cargo.toml Cargo.lock -git commit -m "feat(auth): add argon2, rand, axum-extra dependencies" -``` - ---- - -## Task 2: Auth schema in `database.rs` - -Add `initialize_auth_schema` and update `instance_settings`. - -**Files:** -- Modify: `crates/archivr-core/src/database.rs` - -- [ ] **Step 1: Write failing test for role seeding** - -At the bottom of the `#[cfg(test)]` block in `database.rs`, add: - -```rust -#[test] -fn auth_schema_seeds_builtin_roles() { - let conn = Connection::open_in_memory().unwrap(); - initialize_auth_schema(&conn).unwrap(); - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM roles WHERE is_builtin = 1", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 4); - let owner_bits: i64 = conn - .query_row("SELECT bit_position FROM roles WHERE slug = 'owner'", [], |r| r.get(0)) - .unwrap(); - assert_eq!(owner_bits, 3); -} - -#[test] -fn auth_schema_is_idempotent() { - let conn = Connection::open_in_memory().unwrap(); - initialize_auth_schema(&conn).unwrap(); - initialize_auth_schema(&conn).unwrap(); // must not panic -} -``` - -- [ ] **Step 2: Run to confirm they fail** - -```bash -cd ~/personal/archivr && cargo test -p archivr-core auth_schema 2>&1 | tail -5 -``` - -Expected: FAILED — `initialize_auth_schema` not found. - -- [ ] **Step 3: Add `initialize_auth_schema`** - -After the closing `}` of `initialize_schema` in `database.rs`, add: - -```rust -pub fn initialize_auth_schema(conn: &Connection) -> Result<()> { - conn.pragma_update(None, "journal_mode", "WAL")?; - conn.pragma_update(None, "foreign_keys", "ON")?; - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS roles ( - id INTEGER PRIMARY KEY, - role_uid TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - level INTEGER NOT NULL, - bit_position INTEGER NOT NULL UNIQUE, - is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1)) - ); - - INSERT OR IGNORE INTO roles (role_uid, slug, name, level, bit_position, is_builtin) VALUES - ('role-guest', 'guest', 'Guest', 0, 0, 1), - ('role-user', 'user', 'User', 1, 1, 1), - ('role-admin', 'admin', 'Admin', 3, 2, 1), - ('role-owner', 'owner', 'Owner', 4, 3, 1); - - CREATE TABLE IF NOT EXISTS user_roles ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_id INTEGER NOT NULL REFERENCES roles(id), - assigned_at TEXT NOT NULL, - assigned_by_user_id INTEGER REFERENCES users(id), - PRIMARY KEY (user_id, role_id) - ); - - CREATE TABLE IF NOT EXISTS sessions ( - id INTEGER PRIMARY KEY, - session_uid TEXT NOT NULL UNIQUE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_bits INTEGER NOT NULL, - created_at TEXT NOT NULL, - last_seen_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - user_agent TEXT - ); - CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); - - CREATE TABLE IF NOT EXISTS api_tokens ( - id INTEGER PRIMARY KEY, - token_uid TEXT NOT NULL UNIQUE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - created_at TEXT NOT NULL, - last_used_at TEXT, - expires_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); - - CREATE TABLE IF NOT EXISTS instance_settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)), - public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)), - public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)), - default_entry_visibility INTEGER NOT NULL DEFAULT 2 - ); - - INSERT OR IGNORE INTO instance_settings - (id, public_index_enabled, public_entry_content_enabled, - public_archive_submission_enabled, default_entry_visibility) - VALUES (1, 0, 0, 0, 2); - - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, - user_uid TEXT NOT NULL UNIQUE, - username TEXT NOT NULL UNIQUE, - email TEXT UNIQUE, - password_hash TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), - role TEXT NOT NULL CHECK (role IN ('admin', 'user')), - created_at TEXT NOT NULL, - last_login_at TEXT - ); - "#, - )?; - Ok(()) -} -``` - -Note: `users` is duplicated here with `CREATE TABLE IF NOT EXISTS` so the auth DB is self-contained without needing `initialize_schema`. - -- [ ] **Step 4: Run tests** - -```bash -cd ~/personal/archivr && cargo test -p archivr-core auth_schema 2>&1 | tail -5 -``` - -Expected: 2 tests pass. - -- [ ] **Step 5: Add `open_auth_db` function** - -After `open_or_initialize`, add: - -```rust -pub fn open_auth_db(auth_db_path: &Path) -> Result { - if let Some(parent) = auth_db_path.parent() { - std::fs::create_dir_all(parent).with_context(|| { - format!("failed to create auth DB directory {}", parent.display()) - })?; - } - let conn = Connection::open(auth_db_path).with_context(|| { - format!("failed to open auth database at {}", auth_db_path.display()) - })?; - initialize_auth_schema(&conn)?; - Ok(conn) -} -``` - -- [ ] **Step 6: Verify compilation and commit** - -```bash -cd ~/personal/archivr && cargo test -p archivr-core 2>&1 | tail -3 -``` - -Expected: all existing tests pass. - -```bash -git add crates/archivr-core/src/database.rs -git commit -m "feat(auth): add initialize_auth_schema and open_auth_db" -``` - ---- - -## Task 3: User and role DB helpers - -**Files:** -- Modify: `crates/archivr-core/src/database.rs` - -- [ ] **Step 1: Add record types** - -After the existing struct definitions (before `pub fn database_path`), add: - -```rust -#[derive(Debug, Clone)] -pub struct AuthUserRecord { - pub id: i64, - pub user_uid: String, - pub username: String, - pub password_hash: String, - pub status: String, -} - -#[derive(Debug, Clone)] -pub struct SessionRecord { - pub user_id: i64, - pub role_bits: u32, - pub last_seen_at: String, - pub session_uid: String, -} - -#[derive(Debug, Clone)] -pub struct ApiTokenRecord { - pub token_uid: String, - pub name: String, - pub created_at: String, - pub last_used_at: Option, -} -``` - -- [ ] **Step 2: Write failing tests for user helpers** - -In the `#[cfg(test)]` block, add: - -```rust -fn make_auth_conn() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - initialize_auth_schema(&conn).unwrap(); - conn -} - -#[test] -fn ensure_owner_exists_returns_false_when_no_owner() { - let conn = make_auth_conn(); - assert!(!ensure_owner_exists(&conn).unwrap()); -} - -#[test] -fn create_owner_then_ensure_returns_true() { - let conn = make_auth_conn(); - create_owner(&conn, "alice", "hashed_pw").unwrap(); - assert!(ensure_owner_exists(&conn).unwrap()); -} - -#[test] -fn create_owner_assigns_cumulative_roles() { - let conn = make_auth_conn(); - let user_id = create_owner(&conn, "alice", "hashed_pw").unwrap(); - let bits = compute_role_bits(&conn, user_id).unwrap(); - // guest=1, user=2, admin=4, owner=8 → 15 - assert_eq!(bits, 15u32); -} - -#[test] -fn get_user_by_username_returns_none_for_unknown() { - let conn = make_auth_conn(); - assert!(get_user_by_username(&conn, "nobody").unwrap().is_none()); -} -``` - -- [ ] **Step 3: Run to confirm they fail** - -```bash -cd ~/personal/archivr && cargo test -p archivr-core ensure_owner 2>&1 | tail -5 -``` - -Expected: FAILED. - -- [ ] **Step 4: Implement user/role helpers** - -After `open_auth_db`, add: - -```rust -/// Returns true if an owner account exists. -pub fn ensure_owner_exists(conn: &Connection) -> Result { - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM user_roles ur - JOIN roles r ON r.id = ur.role_id - WHERE r.slug = 'owner'", - [], - |row| row.get(0), - )?; - Ok(count > 0) -} - -/// Creates a user and assigns all roles from `user` up to `owner` (cumulative). -/// `password_hash` must already be hashed by the caller. -pub fn create_owner(conn: &Connection, username: &str, password_hash: &str) -> Result { - let user_uid = public_id("usr"); - conn.execute( - "INSERT INTO users (user_uid, username, email, password_hash, status, role, created_at) - VALUES (?1, ?2, NULL, ?3, 'active', 'admin', ?4)", - params![user_uid, username, password_hash, now_timestamp()], - )?; - let user_id = conn.last_insert_rowid(); - // Assign user, admin, owner (cumulative) - for slug in &["user", "admin", "owner"] { - let role_id: i64 = conn.query_row( - "SELECT id FROM roles WHERE slug = ?1", - [slug], - |row| row.get(0), - )?; - conn.execute( - "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) - VALUES (?1, ?2, ?3)", - params![user_id, role_id, now_timestamp()], - )?; - } - Ok(user_id) -} - -pub fn get_user_by_username(conn: &Connection, username: &str) -> Result> { - conn.query_row( - "SELECT id, user_uid, username, password_hash, status FROM users WHERE username = ?1", - [username], - |row| { - Ok(AuthUserRecord { - id: row.get(0)?, - user_uid: row.get(1)?, - username: row.get(2)?, - password_hash: row.get(3)?, - status: row.get(4)?, - }) - }, - ) - .optional() - .map_err(Into::into) -} - -/// Computes role_bits = ROLE_GUEST | OR(assigned role bit values). -/// ROLE_GUEST (bit 0, value 1) is always included as the implicit floor. -pub fn compute_role_bits(conn: &Connection, user_id: i64) -> Result { - let mut stmt = conn.prepare( - "SELECT (1 << r.bit_position) FROM user_roles ur - JOIN roles r ON r.id = ur.role_id - WHERE ur.user_id = ?1", - )?; - let bits: u32 = stmt - .query_map([user_id], |row| row.get::<_, i64>(0))? - .try_fold(1u32, |acc, val| val.map(|v| acc | v as u32))?; - Ok(bits) -} -``` - -- [ ] **Step 5: Run tests** - -```bash -cd ~/personal/archivr && cargo test -p archivr-core ensure_owner create_owner get_user compute_role 2>&1 | tail -5 -``` - -Expected: all 4 new tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add crates/archivr-core/src/database.rs -git commit -m "feat(auth): user and role DB helpers (create_owner, compute_role_bits)" -``` - ---- - -## Task 4: Session and token DB helpers - -**Files:** -- Modify: `crates/archivr-core/src/database.rs` - -- [ ] **Step 1: Write failing session tests** - -```rust -#[test] -fn create_and_get_session() { - let conn = make_auth_conn(); - let user_id = create_owner(&conn, "alice", "pw").unwrap(); - let uid = create_session(&conn, user_id, 15, None).unwrap(); - let sess = get_session(&conn, &uid).unwrap().unwrap(); - assert_eq!(sess.user_id, user_id); - assert_eq!(sess.role_bits, 15); -} - -#[test] -fn get_session_returns_none_for_unknown() { - let conn = make_auth_conn(); - assert!(get_session(&conn, "nonexistent").unwrap().is_none()); -} - -#[test] -fn delete_session_removes_it() { - let conn = make_auth_conn(); - let user_id = create_owner(&conn, "alice", "pw").unwrap(); - let uid = create_session(&conn, user_id, 15, None).unwrap(); - delete_session(&conn, &uid).unwrap(); - assert!(get_session(&conn, &uid).unwrap().is_none()); -} - -#[test] -fn token_hash_round_trips() { - let conn = make_auth_conn(); - let user_id = create_owner(&conn, "alice", "pw").unwrap(); - create_api_token(&conn, user_id, "hash_abc", "My Token").unwrap(); - let found_id = get_user_for_token(&conn, "hash_abc").unwrap(); - assert_eq!(found_id, Some(user_id)); -} - -#[test] -fn get_user_for_token_returns_none_for_unknown() { - let conn = make_auth_conn(); - assert!(get_user_for_token(&conn, "unknown").unwrap().is_none()); -} -``` - -- [ ] **Step 2: Run to confirm failure** - -```bash -cd ~/personal/archivr && cargo test -p archivr-core create_and_get_session token_hash 2>&1 | tail -5 -``` - -- [ ] **Step 3: Implement session helpers** - -After `compute_role_bits`, add: - -```rust -/// Returns a new session_uid (UUID). -pub fn create_session( - conn: &Connection, - user_id: i64, - role_bits: u32, - user_agent: Option<&str>, -) -> Result { - let session_uid = public_id("sess"); - let now = now_timestamp(); - // expires_at = 30 days from now (approximate via string arithmetic is fragile; - // compute with chrono instead) - let expires_at = chrono::Utc::now() - .checked_add_signed(chrono::Duration::days(30)) - .unwrap() - .format("%Y-%m-%dT%H-%M-%S%.3f") - .to_string(); - conn.execute( - "INSERT INTO sessions (session_uid, user_id, role_bits, created_at, last_seen_at, expires_at, user_agent) - VALUES (?1, ?2, ?3, ?4, ?4, ?5, ?6)", - params![session_uid, user_id, role_bits as i64, now, expires_at, user_agent], - )?; - Ok(session_uid) -} - -/// Returns session if it exists, the user is active, and it has not expired. -pub fn get_session(conn: &Connection, session_uid: &str) -> Result> { - let now = now_timestamp(); - conn.query_row( - "SELECT s.user_id, s.role_bits, s.last_seen_at, s.session_uid - FROM sessions s - JOIN users u ON u.id = s.user_id - WHERE s.session_uid = ?1 - AND u.status = 'active' - AND s.expires_at > ?2", - params![session_uid, now], - |row| { - Ok(SessionRecord { - user_id: row.get(0)?, - role_bits: row.get::<_, i64>(1)? as u32, - last_seen_at: row.get(2)?, - session_uid: row.get(3)?, - }) - }, - ) - .optional() - .map_err(Into::into) -} - -pub fn delete_session(conn: &Connection, session_uid: &str) -> Result<()> { - conn.execute("DELETE FROM sessions WHERE session_uid = ?1", [session_uid])?; - Ok(()) -} - -/// Updates last_seen_at and extends expires_at by 30 days. -pub fn touch_session(conn: &Connection, session_uid: &str) -> Result<()> { - let now = now_timestamp(); - let new_expires = chrono::Utc::now() - .checked_add_signed(chrono::Duration::days(30)) - .unwrap() - .format("%Y-%m-%dT%H-%M-%S%.3f") - .to_string(); - conn.execute( - "UPDATE sessions SET last_seen_at = ?1, expires_at = ?2 WHERE session_uid = ?3", - params![now, new_expires, session_uid], - )?; - Ok(()) -} - -pub fn delete_expired_sessions(conn: &Connection) -> Result { - let now = now_timestamp(); - let n = conn.execute("DELETE FROM sessions WHERE expires_at <= ?1", [now])?; - Ok(n) -} -``` - -- [ ] **Step 4: Implement token helpers** - -```rust -/// Creates an API token. `token_hash` is SHA3-256 hex of the raw token. -/// Returns the token_uid. -pub fn create_api_token( - conn: &Connection, - user_id: i64, - token_hash: &str, - name: &str, -) -> Result { - let token_uid = public_id("tok"); - conn.execute( - "INSERT INTO api_tokens (token_uid, user_id, token_hash, name, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![token_uid, user_id, token_hash, name, now_timestamp()], - )?; - Ok(token_uid) -} - -/// Returns the user_id for a given token hash, if the token is valid and user is active. -pub fn get_user_for_token(conn: &Connection, token_hash: &str) -> Result> { - let now = now_timestamp(); - conn.query_row( - "SELECT t.user_id FROM api_tokens t - JOIN users u ON u.id = t.user_id - WHERE t.token_hash = ?1 - AND u.status = 'active' - AND (t.expires_at IS NULL OR t.expires_at > ?2)", - params![token_hash, now], - |row| row.get(0), - ) - .optional() - .map_err(Into::into) -} - -pub fn touch_token(conn: &Connection, token_uid: &str) -> Result<()> { - conn.execute( - "UPDATE api_tokens SET last_used_at = ?1 WHERE token_uid = ?2", - params![now_timestamp(), token_uid], - )?; - Ok(()) -} - -/// Returns true if the token was found and deleted (user_id must match). -pub fn delete_api_token(conn: &Connection, token_uid: &str, user_id: i64) -> Result { - let n = conn.execute( - "DELETE FROM api_tokens WHERE token_uid = ?1 AND user_id = ?2", - params![token_uid, user_id], - )?; - Ok(n > 0) -} - -pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result> { - let mut stmt = conn.prepare( - "SELECT token_uid, name, created_at, last_used_at - FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC", - )?; - let records = stmt - .query_map([user_id], |row| { - Ok(ApiTokenRecord { - token_uid: row.get(0)?, - name: row.get(1)?, - created_at: row.get(2)?, - last_used_at: row.get(3)?, - }) - })? - .collect::, _>>()?; - Ok(records) -} -``` - -- [ ] **Step 5: Run all new tests** - -```bash -cd ~/personal/archivr && cargo test -p archivr-core 2>&1 | tail -5 -``` - -Expected: all tests pass (including all previously passing ones). - -- [ ] **Step 6: Commit** - -```bash -git add crates/archivr-core/src/database.rs -git commit -m "feat(auth): session and token DB helpers" -``` - ---- - -## Task 5: Auth DB path in AppState, registry, and main.rs - -**Files:** -- Modify: `crates/archivr-server/src/registry.rs` -- Modify: `crates/archivr-server/src/routes.rs` -- Modify: `crates/archivr-server/src/main.rs` - -- [ ] **Step 1: Add `auth_db_path` to `ServerRegistry`** - -In `registry.rs`, update the `ServerRegistry` struct: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -pub struct ServerRegistry { - #[serde(default)] - pub archives: Vec, - /// Optional bind address. Defaults to `127.0.0.1:8080`. - #[serde(default)] - pub bind: Option, - /// Path to the server-level auth database. - /// Defaults to `archivr-auth.sqlite` in the same directory as the config file. - #[serde(default)] - pub auth_db_path: Option, -} -``` - -- [ ] **Step 2: Update `AppState` in `routes.rs`** - -Change `AppState`: - -```rust -#[derive(Clone)] -pub struct AppState { - registry: Arc, - pub auth_db_path: Arc, -} -``` - -Update `app()` signature and body: - -```rust -pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router { - let state = AppState { - registry: Arc::new(registry), - auth_db_path: Arc::new(auth_db_path), - }; - // ... rest unchanged -``` - -- [ ] **Step 3: Update all tests in `routes.rs` that call `app(registry)`** - -Every `app(registry)` in the test module must become `app(registry, std::path::PathBuf::from("/tmp/test-auth.sqlite"))`. - -Search for all occurrences: - -```bash -grep -n "app(registry" ~/personal/archivr/crates/archivr-server/src/routes.rs | head -20 -``` - -Update each one to `app(registry, tempfile::tempdir().unwrap().path().join("auth.sqlite"))`. Add `use tempfile;` if not present. (The `tempfile` crate is already a workspace dependency.) - -- [ ] **Step 4: Update `main.rs`** - -```rust -mod registry; -mod routes; - -use anyhow::{Context, Result}; -use std::{net::SocketAddr, path::PathBuf}; - -const DEFAULT_BIND: &str = "127.0.0.1:8080"; - -#[tokio::main] -async fn main() -> Result<()> { - let config_path = std::env::args() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("archivr-server.toml")); - - let registry = registry::load_registry(&config_path)?; - - // Auth DB lives next to the config file unless overridden. - let auth_db_path = registry.auth_db_path.clone().unwrap_or_else(|| { - config_path - .parent() - .unwrap_or_else(|| std::path::Path::new(".")) - .join("archivr-auth.sqlite") - }); - - let app = routes::app(registry.clone(), auth_db_path.clone()); - - let bind_str = std::env::var("ARCHIVR_BIND") - .ok() - .or_else(|| registry.bind.clone()) - .unwrap_or_else(|| DEFAULT_BIND.to_string()); - - let addr: SocketAddr = bind_str - .parse() - .with_context(|| format!("invalid bind address: {bind_str}"))?; - - let listener = tokio::net::TcpListener::bind(addr).await?; - println!("archivr-server listening on http://{addr}"); - axum::serve(listener, app).await?; - Ok(()) -} -``` - -- [ ] **Step 5: Run full test suite** - -```bash -cd ~/personal/archivr && cargo test 2>&1 | tail -5 -``` - -Expected: all tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add crates/archivr-server/src/routes.rs \ - crates/archivr-server/src/registry.rs \ - crates/archivr-server/src/main.rs -git commit -m "feat(auth): add auth_db_path to AppState, registry, and main.rs" -``` - ---- - -## Task 6: Create `auth.rs` — AuthUser extractor - -**Files:** -- Create: `crates/archivr-server/src/auth.rs` -- Modify: `crates/archivr-server/src/routes.rs` (add `mod auth; use auth::AuthUser;`) - -- [ ] **Step 1: Create `auth.rs`** - -Create `crates/archivr-server/src/auth.rs` with the following content: - -```rust -use anyhow::Result; -use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use argon2::password_hash::{SaltString, rand_core::OsRng}; -use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; -use axum_extra::extract::CookieJar; -use rand::RngCore; - -use crate::routes::{ApiError, AppState}; -use archivr_core::database; - -// ── Role bit constants ──────────────────────────────────────────────────────── -pub const ROLE_GUEST: u32 = 1; // bit 0 -pub const ROLE_USER: u32 = 2; // bit 1 -pub const ROLE_ADMIN: u32 = 4; // bit 2 -pub const ROLE_OWNER: u32 = 8; // bit 3 - -// ── AuthUser ───────────────────────────────────────────────────────────────── -#[derive(Clone, Debug)] -pub enum AuthUser { - Guest, - Authenticated { user_id: i64, role_bits: u32 }, -} - -impl AuthUser { - /// Returns (user_id, role_bits) or 401 if Guest. - pub fn require_auth(&self) -> Result<(i64, u32), ApiError> { - match self { - AuthUser::Authenticated { user_id, role_bits } => Ok((*user_id, *role_bits)), - AuthUser::Guest => Err(ApiError::unauthorized("login required")), - } - } - - /// Returns Ok(()) if the user has the given role bit set, else 401/403. - pub fn require_role(&self, bit: u32) -> Result<(), ApiError> { - match self { - AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0 => Ok(()), - AuthUser::Authenticated { .. } => Err(ApiError::forbidden("insufficient permissions")), - AuthUser::Guest => Err(ApiError::unauthorized("login required")), - } - } - - pub fn has_role(&self, bit: u32) -> bool { - matches!(self, AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0) - } -} - -#[async_trait] -impl FromRequestParts for AuthUser { - type Rejection = std::convert::Infallible; - - async fn from_request_parts( - parts: &mut Parts, - state: &AppState, - ) -> Result { - let auth_db_path = state.auth_db_path.as_ref(); - - // 1. Try session cookie - let jar = CookieJar::from_headers(&parts.headers); - if let Some(cookie) = jar.get("session") { - let session_uid = cookie.value().to_string(); - if let Ok(conn) = database::open_auth_db(auth_db_path) { - if let Ok(Some(session)) = database::get_session(&conn, &session_uid) { - // Conditional touch: only update last_seen_at if more than 60s have elapsed. - // The session row is already in memory, so no extra query needed. - let should_touch = chrono::NaiveDateTime::parse_from_str( - &session.last_seen_at, "%Y-%m-%dT%H-%M-%S%.3f", - ) - .map(|last| { - let last_utc = chrono::DateTime::::from_naive_utc_and_offset( - last, chrono::Utc, - ); - chrono::Utc::now() - last_utc > chrono::Duration::seconds(60) - }) - .unwrap_or(true); // if parse fails, touch anyway - if should_touch { - let _ = database::touch_session(&conn, &session_uid); - } - return Ok(AuthUser::Authenticated { - user_id: session.user_id, - role_bits: session.role_bits, - }); - } - } - } - - // 2. Try Bearer token - if let Some(auth_header) = parts.headers.get("Authorization") { - if let Ok(header_str) = auth_header.to_str() { - if let Some(raw_token) = header_str.strip_prefix("Bearer ") { - let token_hash = hash_token(raw_token); - if let Ok(conn) = database::open_auth_db(auth_db_path) { - if let Ok(Some(user_id)) = database::get_user_for_token(&conn, &token_hash) { - // Get token_uid for touch (find by hash) - // Compute role_bits live for tokens - if let Ok(role_bits) = database::compute_role_bits(&conn, user_id) { - return Ok(AuthUser::Authenticated { user_id, role_bits }); - } - } - } - } - } - } - - Ok(AuthUser::Guest) - } -} - -// ── Password helpers ────────────────────────────────────────────────────────── - -pub fn hash_password(password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - let hash = Argon2::default() - .hash_password(password.as_bytes(), &salt) - .map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))? - .to_string(); - Ok(hash) -} - -pub fn verify_password(password: &str, hash: &str) -> Result { - let parsed = PasswordHash::new(hash) - .map_err(|e| anyhow::anyhow!("invalid password hash: {e}"))?; - Ok(Argon2::default() - .verify_password(password.as_bytes(), &parsed) - .is_ok()) -} - -// ── Token helpers ───────────────────────────────────────────────────────────── - -/// Generates a cryptographically random 32-byte token, base64url-encoded. -pub fn generate_token() -> String { - let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); - base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes) -} - -/// SHA3-256 hex hash of a raw token string. Used for storage and lookup. -pub fn hash_token(raw: &str) -> String { - archivr_core::hash::hash_bytes(raw.as_bytes()) -} -``` - -- [ ] **Step 2: Wire into `routes.rs`** - -At the top of `routes.rs`, add after `mod` declarations: - -```rust -mod auth; -pub use auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER}; -``` - -Also add `unauthorized` and `forbidden` constructors to `ApiError`, and update `IntoResponse` to return JSON: - -```rust -impl ApiError { - // ... existing constructors ... - - pub fn unauthorized(message: &str) -> Self { - Self { status: StatusCode::UNAUTHORIZED, message: message.to_string() } - } - - pub fn forbidden(message: &str) -> Self { - Self { status: StatusCode::FORBIDDEN, message: message.to_string() } - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let body = serde_json::json!({ "error": self.message }); - (self.status, axum::Json(body)).into_response() - } -} -``` - -- [ ] **Step 3: Compile check** - -```bash -cd ~/personal/archivr && cargo check -p archivr-server 2>&1 | tail -10 -``` - -Fix any import errors. Common fix: add `use std::path::Path;` or check `axum_extra` cookie import. - -- [ ] **Step 4: Write extractor tests in `routes.rs`** - -In the `#[cfg(test)]` block in `routes.rs`, add helpers and tests: - -```rust -fn make_test_app() -> (Router, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let auth_db_path = dir.path().join("auth.sqlite"); - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; - (app(registry, auth_db_path), dir) -} - -#[tokio::test] -async fn health_check_returns_ok() { - let (app, _dir) = make_test_app(); - let response = app - .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); -} -``` - -Update the existing `archives_endpoint_lists_mounted_archives` test to use `make_test_app()`: - -```rust -#[tokio::test] -async fn archives_endpoint_lists_mounted_archives() { - let dir = tempfile::tempdir().unwrap(); - let auth_db_path = dir.path().join("auth.sqlite"); - let registry = ServerRegistry { - archives: vec![MountedArchive { - id: "personal".to_string(), - label: "Personal".to_string(), - archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"), - }], - bind: None, - auth_db_path: None, - }; - let response = app(registry, auth_db_path) - .oneshot(Request::builder().uri("/api/archives").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); -} -``` - -Apply the same pattern to **every other test** in the module that calls `app(registry)`. - -- [ ] **Step 5: Run all tests** - -```bash -cd ~/personal/archivr && cargo test 2>&1 | tail -5 -``` - -Expected: all tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add crates/archivr-server/src/auth.rs crates/archivr-server/src/routes.rs -git commit -m "feat(auth): AuthUser extractor, password helpers, token generation" -``` - ---- - -## Task 7: Auth endpoints — login, logout, /me, setup - -**Files:** -- Modify: `crates/archivr-server/src/routes.rs` - -Add the following to `routes.rs`. - -- [ ] **Step 1: Write failing tests for auth endpoints** - -```rust -#[tokio::test] -async fn setup_required_before_owner_created() { - let (app, _dir) = make_test_app(); - let response = app - .oneshot( - Request::builder() - .uri("/api/auth/setup") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["setup_required"], true); -} - -#[tokio::test] -async fn login_with_wrong_password_returns_401() { - let dir = tempfile::tempdir().unwrap(); - let auth_db_path = dir.path().join("auth.sqlite"); - // Seed owner - { - let conn = archivr_core::database::open_auth_db(&auth_db_path).unwrap(); - let hash = crate::auth::hash_password("correct").unwrap(); - archivr_core::database::create_owner(&conn, "owner", &hash).unwrap(); - } - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; - let response = app(registry, auth_db_path) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header("content-type", "application/json") - .body(Body::from(r#"{"username":"owner","password":"wrong"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn setup_post_creates_owner_and_returns_409_on_repeat() { - let (app_once, dir) = make_test_app(); - let auth_db_path = dir.path().join("auth.sqlite"); - let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; - - // First POST creates the owner - let response = app_once - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/setup") - .header("content-type", "application/json") - .body(Body::from(r#"{"username":"owner","password":"hunter2"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::CREATED); - - // Second POST must return 409 - let app2 = app(registry, auth_db_path); - let response2 = app2 - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/setup") - .header("content-type", "application/json") - .body(Body::from(r#"{"username":"owner2","password":"hunter2"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response2.status(), StatusCode::CONFLICT); -} -``` - -- [ ] **Step 2: Run to confirm failure** - -```bash -cd ~/personal/archivr && cargo test -p archivr-server setup_required login_with_wrong 2>&1 | tail -10 -``` - -Expected: FAILED. - -- [ ] **Step 3: Add auth routes to `app()` in `routes.rs`** - -In the `app()` function, add these routes before `.with_state(state)`: - -```rust -.route("/api/auth/setup", get(auth_setup_status).post(auth_setup)) -.route("/api/auth/login", post(auth_login)) -.route("/api/auth/logout", post(auth_logout)) -.route("/api/auth/me", get(auth_me)) -// Setup guard must come AFTER routes are defined and BEFORE .with_state -.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard)) -``` - -- [ ] **Step 3b: Implement `setup_guard` middleware** - -Add this function before the `app()` definition: - -```rust -/// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed. -async fn setup_guard( - State(state): State, - req: axum::extract::Request, - next: axum::middleware::Next, -) -> Response { - let path = req.uri().path(); - let exempt = path == "/api/auth/setup" - || path == "/api/auth/login" - || path.starts_with("/assets") - || path == "/" - || path == "/health"; - if !exempt { - if let Ok(conn) = database::open_auth_db(&state.auth_db_path) { - if matches!(database::ensure_owner_exists(&conn), Ok(false)) { - return ( - StatusCode::SERVICE_UNAVAILABLE, - axum::Json(serde_json::json!({ "error": "setup_required" })), - ) - .into_response(); - } - } - } - next.run(req).await -} -``` - -- [ ] **Step 4: Add request/response types** - -After the existing `#[derive(serde::Deserialize)]` structs, add: - -```rust -#[derive(Debug, serde::Deserialize)] -struct LoginBody { - username: String, - password: String, -} - -#[derive(Debug, serde::Deserialize)] -struct SetupBody { - username: String, - password: String, -} -``` - -- [ ] **Step 5: Implement handler functions** - -Add after the existing handlers: - -```rust -async fn auth_setup_status( - State(state): State, -) -> Result, ApiError> { - let conn = database::open_auth_db(&state.auth_db_path)?; - let required = !database::ensure_owner_exists(&conn)?; - Ok(Json(serde_json::json!({ "setup_required": required }))) -} - -async fn auth_setup( - State(state): State, - Json(body): Json, -) -> Result<(StatusCode, Json), ApiError> { - let conn = database::open_auth_db(&state.auth_db_path)?; - if database::ensure_owner_exists(&conn)? { - return Err(ApiError { - status: StatusCode::CONFLICT, - message: "already_configured".to_string(), - }); - } - if body.username.trim().is_empty() || body.password.len() < 8 { - return Err(ApiError::bad_request("username required and password must be at least 8 characters")); - } - let hash = auth::hash_password(&body.password).map_err(ApiError::from)?; - let user_id = database::create_owner(&conn, &body.username, &hash)?; - let user = database::get_user_by_username(&conn, &body.username)? - .ok_or_else(|| ApiError::internal("user not found after creation"))?; - Ok((StatusCode::CREATED, Json(serde_json::json!({ - "user_uid": user.user_uid, - "username": user.username, - })))) -} - -async fn auth_login( - State(state): State, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> Result<(StatusCode, axum::http::HeaderMap, Json), ApiError> { - let conn = database::open_auth_db(&state.auth_db_path)?; - let user = database::get_user_by_username(&conn, &body.username)? - .filter(|u| u.status == "active") - .ok_or_else(|| ApiError::unauthorized("invalid_credentials"))?; - if !auth::verify_password(&body.password, &user.password_hash) - .map_err(ApiError::from)? - { - return Err(ApiError::unauthorized("invalid_credentials")); - } - let role_bits = database::compute_role_bits(&conn, user.id)?; - let user_agent = headers - .get("user-agent") - .and_then(|v| v.to_str().ok()); - let session_uid = database::create_session(&conn, user.id, role_bits, user_agent)?; - - // Build Set-Cookie header - let secure = headers - .get("x-forwarded-proto") - .and_then(|v| v.to_str().ok()) - .map(|v| v == "https") - .unwrap_or(false); - let cookie_value = format!( - "session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000{}", - session_uid, - if secure { "; Secure" } else { "" } - ); - let mut resp_headers = axum::http::HeaderMap::new(); - resp_headers.insert( - axum::http::header::SET_COOKIE, - cookie_value.parse().map_err(|_| ApiError::internal("cookie serialization failed"))?, - ); - - Ok((StatusCode::OK, resp_headers, Json(serde_json::json!({ - "user_uid": user.user_uid, - "username": user.username, - "role_bits": role_bits, - })))) -} - -async fn auth_logout( - State(state): State, - jar: CookieJar, -) -> Result<(StatusCode, axum::http::HeaderMap), ApiError> { - if let Some(cookie) = jar.get("session") { - let conn = database::open_auth_db(&state.auth_db_path)?; - database::delete_session(&conn, cookie.value())?; - } - let mut resp_headers = axum::http::HeaderMap::new(); - resp_headers.insert( - axum::http::header::SET_COOKIE, - "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0" - .parse() - .unwrap(), - ); - Ok((StatusCode::NO_CONTENT, resp_headers)) -} - -async fn auth_me( - State(state): State, - auth_user: AuthUser, -) -> Result, ApiError> { - let (user_id, role_bits) = auth_user.require_auth()?; - let conn = database::open_auth_db(&state.auth_db_path)?; - // Look up username by user_id for response - let username: String = conn - .query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| r.get(0)) - .map_err(ApiError::from)?; - Ok(Json(serde_json::json!({ - "role_bits": role_bits, - "username": username, - }))) -} -``` - -Add `use axum_extra::extract::CookieJar;` at the top of `routes.rs` imports. - -- [ ] **Step 6: Run tests** - -```bash -cd ~/personal/archivr && cargo test -p archivr-server 2>&1 | tail -5 -``` - -Expected: all tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add crates/archivr-server/src/routes.rs -git commit -m "feat(auth): login, logout, /me, setup endpoints" -``` - ---- - -## Task 8: API token endpoints - -**Files:** -- Modify: `crates/archivr-server/src/routes.rs` - -- [ ] **Step 1: Write failing test** - -```rust -#[tokio::test] -async fn create_token_requires_auth() { - let (app, _dir) = make_test_app(); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/auth/tokens") - .header("content-type", "application/json") - .body(Body::from(r#"{"name":"my token"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} -``` - -- [ ] **Step 2: Add token routes to `app()`** - -```rust -.route("/api/auth/tokens", get(list_tokens).post(create_token)) -.route("/api/auth/tokens/:token_uid", delete(delete_token)) -``` - -- [ ] **Step 3: Add request type** - -```rust -#[derive(Debug, serde::Deserialize)] -struct CreateTokenBody { - name: String, -} -``` - -- [ ] **Step 4: Implement token handlers** - -```rust -async fn create_token( - State(state): State, - auth_user: AuthUser, - Json(body): Json, -) -> Result<(StatusCode, Json), ApiError> { - let (user_id, _) = auth_user.require_auth()?; - if body.name.trim().is_empty() { - return Err(ApiError::bad_request("token name is required")); - } - let raw_token = auth::generate_token(); - let token_hash = auth::hash_token(&raw_token); - let conn = database::open_auth_db(&state.auth_db_path)?; - let token_uid = database::create_api_token(&conn, user_id, &token_hash, &body.name)?; - Ok((StatusCode::CREATED, Json(serde_json::json!({ - "token_uid": token_uid, - "raw_token": raw_token, - "name": body.name, - })))) -} - -async fn list_tokens( - State(state): State, - auth_user: AuthUser, -) -> Result>, ApiError> { - let (user_id, _) = auth_user.require_auth()?; - let conn = database::open_auth_db(&state.auth_db_path)?; - Ok(Json(database::list_user_tokens(&conn, user_id)?)) -} - -async fn delete_token( - State(state): State, - auth_user: AuthUser, - Path(token_uid): Path, -) -> Result { - let (user_id, _) = auth_user.require_auth()?; - let conn = database::open_auth_db(&state.auth_db_path)?; - if database::delete_api_token(&conn, &token_uid, user_id)? { - Ok(StatusCode::NO_CONTENT) - } else { - Err(ApiError::not_found("token not found")) - } -} -``` - -- [ ] **Step 5: Derive `serde::Serialize` on `ApiTokenRecord`** - -In `database.rs`, update the struct: - -```rust -#[derive(Debug, Clone, serde::Serialize)] -pub struct ApiTokenRecord { ... } -``` - -- [ ] **Step 6: Run tests** - -```bash -cd ~/personal/archivr && cargo test -p archivr-server 2>&1 | tail -5 -``` - -Expected: all pass including `create_token_requires_auth`. - -- [ ] **Step 7: Commit** - -```bash -git add crates/archivr-server/src/routes.rs crates/archivr-core/src/database.rs -git commit -m "feat(auth): API token endpoints (create, list, delete)" -``` - ---- - -## Task 9: Route protection for existing routes - -**Files:** -- Modify: `crates/archivr-server/src/routes.rs` - -- [ ] **Step 1: Write failing tests** - -```rust -#[tokio::test] -async fn capture_returns_401_for_unauthenticated() { - let (app, _dir) = make_test_app(); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/archives/test/captures") - .header("content-type", "application/json") - .body(Body::from(r#"{"locator":"https://example.com"}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} -``` - -- [ ] **Step 2: Run to confirm failure** - -```bash -cd ~/personal/archivr && cargo test -p archivr-server capture_returns_401 2>&1 | tail -5 -``` - -Expected: FAILED (currently returns 200 or 404). - -- [ ] **Step 3: Add `AuthUser` to WRITE handlers** - -Update `capture_handler` to require auth: - -```rust -async fn capture_handler( - State(state): State, - auth_user: AuthUser, - Path(archive_id): Path, - Json(body): Json, -) -> Result, ApiError> { - auth_user.require_role(ROLE_USER)?; - // ... rest unchanged -} -``` - -Update `create_tag_handler`, `assign_entry_tag_handler`, `remove_entry_tag_handler` with the same `auth_user: AuthUser` parameter and `auth_user.require_role(ROLE_USER)?` guard. - -- [ ] **Step 4: Update security-boundary comment in `routes.rs`** - -Find the comment block at the top of `routes.rs` (lines 1–23). Replace the route classification list to reflect the new protection: - -```rust -// ── Security Boundary ────────────────────────────────────────────────────── -// -// Route protection tiers: -// STATIC — no auth: GET /, GET /assets/* -// PUBLIC_READ — no auth (visibility filtering deferred to Track 6): -// GET /api/archives, GET /api/archives/:id/entries, etc. -// AUTH — requires login (ROLE_USER bit): -// POST /api/archives/:id/captures -// POST/PUT/DELETE /api/archives/:id/tags -// POST/DELETE /api/archives/:id/entries/:uid/tags -// ADMIN — requires ROLE_ADMIN: -// GET /api/admin/archives (future) -// OWNER — requires ROLE_OWNER: -// instance settings (future) -// AUTH_SELF — no role guard (own resources): -// GET/POST/DELETE /api/auth/tokens -// POST /api/auth/logout -// GET /api/auth/me -// ──────────────────────────────────────────────────────────────────────────── -``` - -- [ ] **Step 5: Run all tests** - -```bash -cd ~/personal/archivr && cargo test 2>&1 | tail -5 -``` - -Expected: all pass. - -- [ ] **Step 6: Commit** - -```bash -git add crates/archivr-server/src/routes.rs -git commit -m "feat(auth): apply ROLE_USER guard to WRITE routes" -``` - ---- - -## Task 10: Session cleanup background task - -**Files:** -- Modify: `crates/archivr-server/src/main.rs` - -- [ ] **Step 1: Add cleanup task to `main.rs`** - -After the `app` binding and before `let listener`, add: - -```rust -// Spawn session cleanup background task: delete expired sessions at startup -// and every 24 hours. -let cleanup_auth_path = auth_db_path.clone(); -tokio::spawn(async move { - loop { - if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) { - match archivr_core::database::delete_expired_sessions(&conn) { - Ok(n) if n > 0 => eprintln!("info: cleaned up {n} expired sessions"), - Err(e) => eprintln!("warn: session cleanup failed: {e:#}"), - _ => {} - } - } - tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await; - } -}); -``` - -Add `use archivr_core;` at the top if not already present (it's brought in transitively via routes). - -- [ ] **Step 2: Verify compilation** - -```bash -cd ~/personal/archivr && cargo check -p archivr-server 2>&1 | tail -5 -``` - -Expected: compiles cleanly. - -- [ ] **Step 3: Commit** - -```bash -git add crates/archivr-server/src/main.rs -git commit -m "feat(auth): session cleanup background task (24h interval)" -``` - ---- - -## Task 11: Frontend — auth state, api.js, LoginPage, SetupPage - -**Files:** -- Modify: `frontend/src/App.jsx` -- Modify: `frontend/src/api.js` -- Create: `frontend/src/components/LoginPage.jsx` -- Create: `frontend/src/components/SetupPage.jsx` - -- [ ] **Step 1: Update `api.js`** - -Open `frontend/src/api.js`. Add auth helper functions and the 401 interceptor at the bottom of the file (replace or append to the existing `export` structure): - -```js -// ── Auth helpers ───────────────────────────────────────────────────────────── - -export async function checkSetup() { - const r = await fetch('/api/auth/setup'); - const data = await r.json(); - return data.setup_required === true; -} - -export async function doSetup(username, password) { - const r = await fetch('/api/auth/setup', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ username, password }), - }); - if (!r.ok) throw new Error((await r.json()).error || 'Setup failed'); - return r.json(); -} - -export async function login(username, password) { - const r = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ username, password }), - }); - if (!r.ok) throw new Error((await r.json()).error || 'Login failed'); - return r.json(); // { user_uid, username, role_bits } -} - -export async function logout() { - await fetch('/api/auth/logout', { method: 'POST' }); -} - -export async function fetchMe() { - const r = await fetch('/api/auth/me'); - if (r.status === 401) return null; - return r.json(); -} - -// ── 401 interceptor ─────────────────────────────────────────────────────────── -// Wrap fetch so any 401 dispatches a custom event for App.jsx to handle. -const _origFetch = window.fetch; -window.fetch = async (...args) => { - const r = await _origFetch(...args); - if (r.status === 401) { - const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? ''; - // Don't intercept auth endpoints themselves - if (!url.includes('/api/auth/')) { - window.dispatchEvent(new CustomEvent('auth:expired')); - } - } - return r; -}; -``` - -- [ ] **Step 2: Create `LoginPage.jsx`** - -```jsx -import { useState } from 'react'; -import { login } from '../api.js'; - -export default function LoginPage({ onLogin }) { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e) { - e.preventDefault(); - setError(null); - setLoading(true); - try { - const user = await login(username, password); - onLogin(user); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - } - - return ( -
-

Archivr

-
- - - {error &&

{error}

} - -
-
- ); -} -``` - -- [ ] **Step 3: Create `SetupPage.jsx`** - -```jsx -import { useState } from 'react'; -import { doSetup } from '../api.js'; - -export default function SetupPage({ onComplete }) { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e) { - e.preventDefault(); - if (password !== confirm) { - setError('Passwords do not match'); - return; - } - if (password.length < 8) { - setError('Password must be at least 8 characters'); - return; - } - setError(null); - setLoading(true); - try { - await doSetup(username, password); - onComplete(); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - } - - return ( -
-

Welcome to Archivr

-

Create your owner account to get started.

-
- - - - {error &&

{error}

} - -
-
- ); -} -``` - -- [ ] **Step 4: Update `App.jsx`** - -Open `frontend/src/App.jsx`. Add the following at the top (after existing imports): - -```jsx -import { createContext, useContext, useState, useEffect, useCallback } from 'react'; -import { checkSetup, fetchMe, logout as apiLogout } from './api.js'; -import LoginPage from './components/LoginPage.jsx'; -import SetupPage from './components/SetupPage.jsx'; - -export const AuthContext = createContext(null); -``` - -Inside the `App` function, add state and effects at the top: - -```jsx -const [authState, setAuthState] = useState('loading'); // 'loading' | 'setup' | 'login' | 'authenticated' -const [currentUser, setCurrentUser] = useState(null); - -useEffect(() => { - (async () => { - const needsSetup = await checkSetup(); - if (needsSetup) { setAuthState('setup'); return; } - const user = await fetchMe(); - if (!user) { setAuthState('login'); return; } - setCurrentUser(user); - setAuthState('authenticated'); - })(); -}, []); - -// Listen for 401s from the fetch interceptor in api.js -useEffect(() => { - const handler = () => { setCurrentUser(null); setAuthState('login'); }; - window.addEventListener('auth:expired', handler); - return () => window.removeEventListener('auth:expired', handler); -}, []); -``` - -In the `App` return, add guards at the very top of the JSX: - -```jsx -if (authState === 'loading') return
Loading…
; -if (authState === 'setup') return setAuthState('login')} />; -if (authState === 'login') return { setCurrentUser(user); setAuthState('authenticated'); }} />; -``` - -Wrap the existing return content in: - -```jsx -return ( - - {/* existing app JSX */} - -); -``` - -- [ ] **Step 5: Build to check for errors** - -```bash -cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -10 -``` - -Expected: builds successfully, no type errors. - -- [ ] **Step 6: Commit** - -```bash -cd ~/personal/archivr -git add frontend/src/App.jsx frontend/src/api.js \ - frontend/src/components/LoginPage.jsx \ - frontend/src/components/SetupPage.jsx -git commit -m "feat(auth): frontend login/setup pages + auth state in App.jsx" -``` - ---- - -## Task 12: Topbar — user menu and logout button - -**Files:** -- Modify: `frontend/src/components/Topbar.jsx` - -- [ ] **Step 1: Update `Topbar.jsx`** - -Open `frontend/src/components/Topbar.jsx`. Import `AuthContext` and `logout`: - -```jsx -import { useContext, useState } from 'react'; -import { AuthContext } from '../App.jsx'; -import { logout as apiLogout } from '../api.js'; -``` - -Inside the component, add: - -```jsx -const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}; -const [loggingOut, setLoggingOut] = useState(false); - -async function handleLogout() { - setLoggingOut(true); - await apiLogout(); - setCurrentUser(null); - window.location.reload(); // simplest way to reset all state -} -``` - -In the JSX, add a user menu at the end of the topbar: - -```jsx -{currentUser && ( -
- {currentUser.username} - -
-)} -``` - -- [ ] **Step 2: Build to check for errors** - -```bash -cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -5 -``` - -Expected: clean build. - -- [ ] **Step 3: Commit** - -```bash -cd ~/personal/archivr -git add frontend/src/components/Topbar.jsx -git commit -m "feat(auth): user menu and logout button in Topbar" -``` - ---- - -## Task 13: Update NEXT.md - -**Files:** -- Modify: `NEXT.md` - -- [ ] **Step 1: Add Track 4 as done, renumber remaining tracks** - -Open `NEXT.md`. Add a new Track 4 section (done) after Track 3, following the same ~~strikethrough~~ ✅ pattern as Tracks 1 and 2. Rename the old Track 4 (Cloud backup) to Track 9 and Track 5 (Cloud storage) to Track 10. Add stub sections for new Tracks 5, 6, 7, 8 with brief descriptions and "pending" status. - -Use this numbering: - -| # | Track | Status | -|---|---|---| -| 1 | Generic URL capture | Done | -| 2 | Web page archiving | Done | -| 3 | Async capture jobs | Pending | -| **4** | **Auth foundation** | **Done (this track)** | -| 5 | User management | Pending | -| 6 | Permissions & visibility | Pending | -| 7 | Settings | Pending | -| 8 | Collections UI | Pending | -| 9 | Cloud backup | Pending (was 4) | -| 10 | Cloud storage | Pending (was 5) | - -- [ ] **Step 2: Commit** - -```bash -git add NEXT.md -git commit -m "docs: mark Track 4 auth foundation done, renumber roadmap tracks" -``` - ---- - -## Final verification - -- [ ] **Run the full test suite** - -```bash -cd ~/personal/archivr && cargo test 2>&1 | tail -5 -``` - -Expected: all tests pass (count will be higher than the pre-implementation baseline due to new tests). - -- [ ] **Build the frontend** - -```bash -cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -5 -``` - -Expected: builds cleanly. diff --git a/docs/superpowers/plans/2026-07-20-playlist-children.md b/docs/superpowers/plans/2026-07-20-playlist-children.md deleted file mode 100644 index fd56f2f..0000000 --- a/docs/superpowers/plans/2026-07-20-playlist-children.md +++ /dev/null @@ -1,220 +0,0 @@ -# Playlist / Channel Captures + Parent–Child Entry Display - -**Date:** 2026-07-20 -**Branch:** `feat-captures-with-children` - ---- - -## Goal - -1. **YouTube (Music) playlists and channels** — when the user submits a playlist or channel URL/shorthand, archive every video in it as a tree of entries: one root "container" entry for the playlist/channel, one child entry per video. -2. **Parent–child display** — the entry list and detail view can show children of a container entry in a collapsible inline section, replacing the current flat/single-entry-only model. - ---- - -## Schema Contract (no migrations needed — columns already exist) - -| Column | Meaning | -|---|---| -| `archived_entries.parent_entry_id` | `NULL` for root entries; points to container id for children | -| `archived_entries.root_entry_id` | Same as `parent_entry_id` for depth-1 children (direct children of a container) | -| `archive_run_items.parent_item_id` | `NULL` for the container item; points to container item id for video items | -| `archive_runs.requested_count` | Always `1` (user submitted one locator) | -| `archive_runs.discovered_count` | `N + 1` — container item + N video items (from `refresh_run_counters`) | -| `archive_runs.completed_count` | Items completed (container + each successful video) | -| `archive_runs.failed_count` | Items that failed to download | - -**Counter note:** `refresh_run_counters` counts every `archive_run_items` row, so `discovered_count = 1 + N` when a playlist has N videos. This is intentional and accurate — the container item itself counts as a discovered/completed item. - ---- - -## yt-dlp Probe Strategy - -For container sources (playlist, channel, YouTube Music playlist), use: - -```bash -yt-dlp -J --flat-playlist -``` - -`-J` (`--dump-single-json`) returns a **single JSON object** for the whole container — reliable `title` and `uploader` fields at the top level, plus an `entries` array (shallow per-item objects with at minimum `id`, `url`, `title`). This avoids the per-item `playlist_title` reliability issue that `--dump-json` (multi-line) has. - -Individual child video downloads stay single-item (`--no-playlist`), same as current video capture. - ---- - -## File Map - -| File | Action | What changes | -|---|---|---| -| `crates/archivr-core/src/downloader/ytdlp.rs` | Modify | Add `PlaylistItem`, `PlaylistInfo` structs; `fetch_playlist_info()` | -| `crates/archivr-core/src/capture.rs` | Modify | `record_media_entry` gains `parent_entry_id`/`root_entry_id` params; add `record_container_entry()`; implement playlist/channel/YTM-playlist capture paths | -| `crates/archivr-core/src/archive.rs` | Modify | Add `child_count: i64` to `EntrySummary`; add `get_entry_summary()`; fix `get_entry_detail()` to handle child entries; add `list_child_entries()` | -| `crates/archivr-server/src/routes.rs` | Modify | Add `GET /api/archives/:id/entries/:uid/children` route + handler | -| `frontend/src/api.js` | Modify | Add `fetchEntryChildren()` | -| `frontend/src/components/EntryRow.jsx` | Modify | Expand toggle when `child_count > 0`; inline child sub-rows on expand | -| `frontend/src/styles.css` | Modify | Child row indentation + expand toggle styling | - ---- - -## Detailed Design - -### `ytdlp.rs` additions - -```rust -pub struct PlaylistItem { - pub id: String, - pub url: String, - pub title: Option, - pub uploader: Option, -} - -pub struct PlaylistInfo { - pub playlist_id: String, - pub title: Option, - pub uploader: Option, - pub items: Vec, -} - -/// Runs `yt-dlp -J --flat-playlist ` and parses the result. -/// Returns Err if yt-dlp fails or the output isn't a playlist object. -pub fn fetch_playlist_info(url: &str, cookies: &HashMap) -> Result -``` - -### `capture.rs` changes - -**`record_media_entry` signature extension:** -```rust -fn record_media_entry( - ..., - title: Option, - parent_entry_id: Option, // NEW - root_entry_id: Option, // NEW -) -> Result -``` -All existing call sites pass `None, None`. Playlist child calls pass the container entry's ids. - -**New `record_container_entry()`:** -- Creates an entry with no blob and no `primary_media` artifact. -- `source_kind/entity_kind` from `source_metadata(source)`. -- `parent_entry_id: None, root_entry_id: None`. -- Stores playlist metadata in `source_metadata_json`. -- Returns the `ArchivedEntry` (needed for child `parent_entry_id`). -- Calls `database::complete_archive_run_item()` on the container run item. - -**Playlist/channel capture path (replaces `return Err(...)` stubs):** -``` -1. fetch_playlist_info(url, cookies) → PlaylistInfo -2. create_archive_run(conn, user_id, 1) → run (requested_count=1) -3. create_archive_run_item(run, None, 0, ..., "playlist"/"channel", "container") -4. record_container_entry(...) → container_entry -5. complete_archive_run_item(container_item, container_entry.id) -6. for (ordinal, item) in playlist_info.items: - a. create_archive_run_item(run, Some(container_item.id), ordinal, item.url, ...) - b. fetch_metadata(item.url, cookies) → metadata_json (for title) - c. ytdlp::download(item.url, ...) → (hash, ext) - d. record_media_entry(..., Some(container.id), Some(container.id)) - OR: fail_archive_run_item(child_item, error) and continue -7. finish_archive_run(conn, run.id) -``` - -Error handling: if a child video fails, call `fail_archive_run_item` and continue — partial success is correct for playlists. - -### `archive.rs` changes - -**`EntrySummary` new field:** -```rust -pub child_count: i64, // number of direct children; 0 for non-container entries -``` - -**`ENTRY_SELECT_COLS` extension** (adds col 12): -```sql -(SELECT COUNT(*) FROM archived_entries child WHERE child.parent_entry_id = e.id) AS child_count -``` - -**`list_root_entries` inline SQL** also extended the same way (col 12). - -All `query_map` closures that build `EntrySummary` get `child_count: row.get(12)?`. - -**`get_entry_summary(conn, entry_uid)`** — new private helper: -- Fetches one entry by uid without the `parent_entry_id IS NULL` constraint. -- Used by the fixed `get_entry_detail`. - -**`get_entry_detail` fix:** -```rust -// BEFORE (broken for child entries): -let summary = list_root_entries(conn, u32::MAX)? - .into_iter() - .find(|entry| entry.entry_uid == entry_uid) - .context("entry disappeared")?; - -// AFTER: -let summary = get_entry_summary(conn, entry_uid)? - .context("entry disappeared")?; -``` - -**New `list_child_entries(conn, parent_uid) -> Result>`:** -```sql -ENTRY_SELECT_COLS ENTRY_FROM_JOINS -WHERE e.parent_entry_id = (SELECT id FROM archived_entries WHERE entry_uid = ?1) -GROUP BY e.id -ORDER BY e.archived_at ASC, e.id ASC -``` -(ascending order — preserves playlist ordinal feel) - -### `routes.rs` addition - -``` -.route( - "/api/archives/:archive_id/entries/:entry_uid/children", - get(list_entry_children), -) -``` - -```rust -async fn list_entry_children( - State(state): State, - auth: AuthUser, - Path((archive_id, entry_uid)): Path<(String, String)>, -) -> Result>, ApiError> { - auth.require_auth()?; - let mounted = mounted_archive(&state, &archive_id)?; - let conn = database::open_or_initialize(&mounted.archive_path)?; - Ok(Json(archive::list_child_entries(&conn, &entry_uid)?)) -} -``` - -### Frontend changes - -**`api.js`:** -```js -export async function fetchEntryChildren(archiveId, entryUid) { - return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`); -} -``` - -**`EntryRow.jsx`:** -- Accept optional `archiveId` for child fetching (already passed). -- When `entry.child_count > 0`: render a chevron expand button in `col-title` area. -- Local state: `expanded` (bool), `children` (array | null), `loading` (bool). -- On chevron click: toggle; if expanding and `children === null`, call `fetchEntryChildren` and store result. -- Render children as `
` containing simplified `` elements (or reuse `EntryRow` without nesting). - -**`styles.css`:** -- `.child-entries` — slight left indent, separator line. -- `.entry-expand-btn` — minimal chevron button. - ---- - -## Acceptance Criteria - -- `cargo test -p archivr-core` green. -- `cargo check -p archivr-server` clean. -- Submitting `yt:playlist/PLxxx` archives the playlist as a container entry with N child video entries in the DB. -- Submitting `yt:@handle` or `yt:channel/UC...` does the same for channel uploads. -- Submitting `ytm:playlist/PLxxx` does the same for YouTube Music playlists. -- Individual video/track captures (`yt:video/ID`, `ytm:ID`) unchanged. -- If one video in a playlist fails, the run status is `failed` but other children are still archived. -- `GET /api/archives/:id/entries` returns root entries only, each with correct `child_count`. -- `GET /api/archives/:id/entries/:uid` works for both root and child entries. -- `GET /api/archives/:id/entries/:uid/children` returns the child entries for a container. -- Frontend expand button appears on container entries; clicking it fetches and shows children inline. diff --git a/docs/superpowers/specs/2026-06-25-auth-foundation-design.md b/docs/superpowers/specs/2026-06-25-auth-foundation-design.md deleted file mode 100644 index 23e74c8..0000000 --- a/docs/superpowers/specs/2026-06-25-auth-foundation-design.md +++ /dev/null @@ -1,403 +0,0 @@ -# Auth Foundation Design - -**Track:** 4 of the roadmap (inserted after Track 3: Async capture jobs) -**Date:** 2026-06-25 -**Status:** Approved for implementation - ---- - -## Context & Roadmap Position - -Archivr is evolving from a local-only tool (single hard-coded user, 127.0.0.1 binding) into a -self-hosted multi-user platform — think ArchiveBox but with real accounts, roles, and -public/private visibility. This track lays the foundation. All subsequent tracks depend on it. - -**Full decomposition:** - -| Track | Scope | Depends on | -|---|---|---| -| 4 (this) | Auth foundation | — | -| 5 | User management — registration, custom roles, admin panel | Track 4 | -| 6 | Permissions & visibility — collection model, per-membership visibility | Track 5 | -| 7 | Settings — account profile, instance-wide toggles | Track 5 | -| 8 | Collections UI | Tracks 5–6 | - ---- - -## Goals - -- Password-protected login with cookie sessions and API tokens -- Role table with bitmask-based visibility (extensible to custom roles in Track 5) -- Auth middleware that protects write/admin routes -- First-run owner setup wizard -- Frontend login page and session-aware API calls - -## Non-Goals (explicitly deferred) - -- Custom role creation UI → Track 5 -- User registration flow → Track 5 -- Visibility enforcement on queries → Track 6 -- Collection model (replacing `archived_entries.visibility`) → Track 6 -- Account settings page → Track 7 -- API token management UI → Track 7 - ---- - -## Schema - -### New table: `roles` - -```sql -CREATE TABLE IF NOT EXISTS roles ( - id INTEGER PRIMARY KEY, - role_uid TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, -- 'guest', 'user', 'admin', 'owner', or custom - name TEXT NOT NULL, - level INTEGER NOT NULL, -- ordering: guest=0, user=1, admin=3, owner=4 - bit_position INTEGER NOT NULL UNIQUE, -- position in visibility bitmask - is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1)) -); -``` - -**Built-in rows seeded at schema init:** - -| slug | level | bit_position | bit value | is_builtin | -|---|---|---|---|---| -| guest | 0 | 0 | 1 | 1 | -| user | 1 | 1 | 2 | 1 | -| admin | 3 | 2 | 4 | 1 | -| owner | 4 | 3 | 8 | 1 | - -Bit position 2 (value 4) is reserved for `admin`. Bit positions 4+ (values 16, 32, …) are assigned -to custom roles in Track 5. Level 2 is reserved for custom roles sitting between `user` and `admin`. - -**role_bits computation — implicit guest floor:** - -`role_bits` for any **authenticated** user is computed as: -``` -role_bits = ROLE_GUEST | (OR of bit values for all rows in user_roles) -``` -The `ROLE_GUEST` bit (1) is always included for authenticated users so they can access -public (guest-visible) content. Example: an owner assigned only the `owner` role gets -`role_bits = 1 | 8 = 9`, which passes `role_bits & ROLE_USER (2) = 0` — still broken. - -**Therefore, role assignment is cumulative by level.** When a role is assigned, all -built-in roles at lower levels are also assigned: -- Assigning `owner` (level 4) → also assign `admin`, `user` in `user_roles` -- Assigning `admin` (level 3) → also assign `user` in `user_roles` -- Assigning `user` (level 1) → no additional rows -- `guest` is never assigned; it is the implicit unauthenticated floor - -Setup creates owner with three `user_roles` rows: `user`, `admin`, `owner`. -Resulting `role_bits = ROLE_GUEST | ROLE_USER | ROLE_ADMIN | ROLE_OWNER = 1|2|4|8 = 15`. - -**Visibility check:** `viewer.role_bits & content.visibility != 0` passes if the viewer -has any bit the content requires. Owner (15) can see everything. User (1|2=3) can see -guest-visible (1) and user-visible (2) content but not admin-only (4). ✓ - -`is_builtin = 1` rows cannot be deleted. - -### New table: `sessions` - -```sql -CREATE TABLE IF NOT EXISTS sessions ( - id INTEGER PRIMARY KEY, - session_uid TEXT NOT NULL UNIQUE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_bits INTEGER NOT NULL, -- snapshot of bitmask at login time; role changes take effect on next login - created_at TEXT NOT NULL, - last_seen_at TEXT NOT NULL, - expires_at TEXT NOT NULL, -- 30 days from last_seen_at - user_agent TEXT -); -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); -``` - -### New table: `api_tokens` - -```sql -CREATE TABLE IF NOT EXISTS api_tokens ( - id INTEGER PRIMARY KEY, - token_uid TEXT NOT NULL UNIQUE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of raw token; raw token never stored - name TEXT NOT NULL, - created_at TEXT NOT NULL, - last_used_at TEXT, - expires_at TEXT -- NULL = never expires -); -CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); -``` - -### `users` table — existing, minimally changed - -The existing `role TEXT NOT NULL CHECK (role IN ('admin','user'))` column is **kept but inert** — -auth middleware reads from `user_roles`, not this column. It will be removed in Track 5 cleanup. -`ensure_owner_exists` must supply a value for this column; use `'admin'` as the placeholder. - -`ensure_default_user` is replaced by `ensure_owner_exists` which returns `false` if no owner -row exists in `user_roles` (triggers setup mode). The old local-admin stub is never created on -fresh instances. Session lookup JOINs `users` and checks `users.status = 'active'`; a session -belonging to a disabled user resolves to `AuthUser::Guest`. - -### `instance_settings` — one new column - -The column is added **inside** the existing `CREATE TABLE IF NOT EXISTS instance_settings` DDL, -not via `ALTER TABLE` (which is not idempotent in `initialize_schema`): - -```sql -CREATE TABLE IF NOT EXISTS instance_settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)), - public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)), - public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)), - default_entry_visibility INTEGER NOT NULL DEFAULT 2 -- 2 = user-visible by default -); -``` - -The existing `INSERT OR IGNORE INTO instance_settings … VALUES (1, 0, 0, 0)` seed row must be -updated to include the new column: `VALUES (1, 0, 0, 0, 2)`. - -### `archived_entries.visibility` — deprecated, not removed - -Flagged with a `-- DEPRECATED: replaced by collection_entries.visibility in Track 6` comment in -`initialize_schema`. No data migration needed yet; Track 6 handles it. - ---- - -## Auth Flow - -### Login - -``` -POST /api/auth/login -Body: { username: string, password: string } -``` - -1. Look up user by username. -2. Verify password with Argon2id (`argon2` crate). -3. Compute `role_bits = ROLE_GUEST | (OR of bit values for all user_roles rows)` (cumulative; see Schema § role_bits computation). -4. Insert `sessions` row (`session_uid` = UUID, `expires_at` = now + 30 days). -5. Set-Cookie: `session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000`. - Add `Secure` flag when the request arrived over HTTPS (detected via `X-Forwarded-Proto: https` - header or TLS connection info). Omit `Secure` for plain HTTP to support local dev without TLS. -6. Return `200 { user_uid, username, role_bits }`. - -On failure: `401 { error: "invalid_credentials" }` — same message for unknown user and wrong -password (no user enumeration). - -### Logout - -``` -POST /api/auth/logout -``` - -Deletes the `sessions` row for the current session cookie. Responds with -`Set-Cookie: session=; Max-Age=0` to clear the browser cookie. Returns `204`. - -### Current user - -``` -GET /api/auth/me -``` - -Returns `200 { user_uid, username, role_bits }` for an authenticated request, or `401` for a -guest. The frontend calls this once on mount to restore session state. - -### First-run setup - -``` -GET /api/auth/setup → 200 { setup_required: bool } -POST /api/auth/setup → 201 { user_uid, username } -Body: { username: string, password: string } -``` - -`setup_required` is `true` when no user has the `owner` role in `user_roles`. On `POST`: -- If setup is **already complete** (an owner exists): return `409 { error: "already_configured" }`. -- Otherwise: create the user (with `users.role = 'admin'` as placeholder), assign `user_roles` - rows for `user`, `admin`, `owner` (cumulative), seed `instance_settings` row if absent. - Return `201 { user_uid, username }`. Normal login flow applies immediately after. - -All non-setup API routes return `503 { error: "setup_required" }` until setup is complete. -The following routes are **exempt** from the 503 check: `GET /api/auth/setup`, -`POST /api/auth/setup`, `GET /` (static), `GET /assets/*` (static). - -### API tokens - -``` -POST /api/auth/tokens → 201 { token_uid, raw_token, name, created_at } -GET /api/auth/tokens → 200 [{ token_uid, name, created_at, last_used_at }] -DELETE /api/auth/tokens/:token_uid → 204 -``` - -`raw_token` is a cryptographically random 32-byte value, base64url-encoded, returned once. The -server stores only its SHA-256 hash. The management UI for these endpoints is in Track 7; the -endpoints are implemented here. - -### Password hashing - -Argon2id with default parameters from the `argon2` crate (memory=19 MiB, iterations=2, -parallelism=1). The current `"disabled-local-password"` sentinel in `ensure_default_user` becomes -irrelevant once setup is required on fresh instances. - -### Session expiry & cleanup - -`last_seen_at` is updated on every authenticated request using a **conditional update**: the -session row is already read during extraction; if `now() - last_seen_at > 60s`, issue an UPDATE. -This adds no extra query — only an extra UPDATE when the threshold is crossed. -`expires_at` = `last_seen_at + 30 days`, recalculated on each UPDATE. A background task in -`archivr-server/src/main.rs` runs `DELETE FROM sessions WHERE expires_at < now()` at startup -and every 24 hours via `tokio::time::interval`. - ---- - -## Auth Extractor - -New file: `crates/archivr-server/src/auth.rs` - -```rust -pub enum AuthUser { - Guest, - Authenticated { user_id: i64, role_bits: u32 }, -} - -impl AuthUser { - pub fn require_auth(&self) -> Result<(i64, u32), ApiError> // 401 if Guest - pub fn require_role(&self, bit: u32) -> Result<(), ApiError> // 403 if bit not set - pub fn has_role(&self, bit: u32) -> bool -} - -// Role bit constants -pub const ROLE_GUEST: u32 = 1; -pub const ROLE_USER: u32 = 2; -pub const ROLE_ADMIN: u32 = 4; -pub const ROLE_OWNER: u32 = 8; -``` - -Implemented as an Axum `FromRequestParts` extractor. Tries `session` cookie first, then -`Authorization: Bearer` header. -- **Cookie path**: look up `sessions` row JOIN `users` WHERE `session_uid = ?` - AND `users.status = 'active'` AND `expires_at > now()`. Use cached `role_bits` from the - session row. -- **Bearer path**: SHA-256 the token, look up `api_tokens` row JOIN `users` WHERE - `token_hash = ?` AND `users.status = 'active'` AND (`expires_at IS NULL OR expires_at > now()`). - Compute `role_bits` live: `ROLE_GUEST | (OR of user_roles bit values for that user)`. - Update `api_tokens.last_used_at`. -- Missing or invalid credential → `AuthUser::Guest` (never a hard error at extraction time). - ---- - -## Route Protection Tiers - -The existing security-boundary comment block in `routes.rs` is updated: - -| Tier | Requirement | Examples | -|---|---|---| -| `STATIC` | none | `GET /`, `GET /assets/*` | -| `PUBLIC_READ` | none (visibility filtering deferred to Track 6) | `GET /api/archives/:id/entries` | -| `AUTH_READ` | `ROLE_USER` bit | authenticated entry access | -| `WRITE` | `ROLE_USER` bit | `POST /api/archives/:id/captures`, tag mutations | -| `ADMIN` | `ROLE_ADMIN` bit | `GET /api/admin/archives`, user management | -| `OWNER` | `ROLE_OWNER` bit | instance settings, ownership transfer | - -**Error responses:** -- No/invalid session → `401` (frontend redirects to login) -- Valid session, insufficient role → `403` -- Private resource accessed without sufficient role → `404` (do not reveal existence) - -Track 4 applies `ROLE_USER` enforcement to all existing `WRITE` routes and `ROLE_ADMIN` to -`/api/admin/*`. `PUBLIC_READ` routes return all data for now; Track 6 adds visibility filters. - ---- - -## Frontend Changes - -### New components - -| Component | Purpose | -|---|---| -| `SetupPage.jsx` | First-run owner account creation wizard | -| `LoginPage.jsx` | Username/password login form | - -### App.jsx changes - -- On mount: call `GET /api/auth/setup`; if `setup_required`, render `` and nothing else. -- Otherwise: call `GET /api/auth/me`; store result as `currentUser` state (null = guest). -- Pass `currentUser` down via React context (`AuthContext`). -- Any `401` response from any API call sets `currentUser` to null → triggers ``. - -### api.js changes - -- Thin response interceptor: if status is `401`, dispatch a global `auth:expired` event that - `App.jsx` listens to and handles by clearing `currentUser`. -- No token storage in JS — cookies are handled entirely by the browser. - -### Topbar.jsx changes - -- When `currentUser` is set: show `username` and a **Log out** button. -- Log out calls `POST /api/auth/logout`, then clears `currentUser`. - -### What is NOT in Track 4 frontend - -- Settings page (Track 7) -- User management UI (Track 5) -- Role or visibility controls (Track 6) -- API token management UI (Track 7) - ---- - -## New Dependencies - -| Crate | Purpose | -|---|---| -| `argon2` | Password hashing (Argon2id) | -| `rand` | Cryptographically random token generation | -| `tower-cookies` | Cookie extraction in Axum (or use `axum-extra`) | - -Add to `archivr-server/Cargo.toml` and workspace `Cargo.toml` as needed. - ---- - -## Files Changed - -| File | Change | -|---|---| -| `crates/archivr-core/src/database.rs` | Add `roles`, `user_roles`, `sessions`, `api_tokens` tables; seed built-in roles; add `instance_settings.default_entry_visibility`; replace `ensure_default_user` with `ensure_owner_exists`; add session/token CRUD helpers | -| `crates/archivr-server/src/auth.rs` | New: `AuthUser` extractor, role bit constants, session/token lookup | -| `crates/archivr-server/src/routes.rs` | Add auth endpoints (`/api/auth/*`); apply `AuthUser` extractor to WRITE/ADMIN routes; update security-boundary comment | -| `crates/archivr-server/src/main.rs` | Session cleanup background task | -| `frontend/src/App.jsx` | Setup check, auth state, `AuthContext` | -| `frontend/src/api.js` | 401 interceptor | -| `frontend/src/components/LoginPage.jsx` | New | -| `frontend/src/components/SetupPage.jsx` | New | -| `frontend/src/components/Topbar.jsx` | User menu + logout | -| `Cargo.toml` | Add `argon2`, `rand`, `tower-cookies` (or `axum-extra`) | - ---- - -## Test Coverage - -- `database.rs`: role seeding, `ensure_owner_exists`, session CRUD, token hash round-trip -- `auth.rs`: extractor resolves cookie → session → user; extractor resolves Bearer → token → user; - missing credential → Guest; expired session → Guest -- `routes.rs`: login happy path; login wrong password returns 401; logout clears session; - setup endpoint returns 503 after setup complete; WRITE route returns 401 for Guest; - WRITE route returns 403 for insufficient role; setup flow end-to-end - ---- - -## Track Numbering Update for NEXT.md - -Original tracks 4 and 5 shift to 8 and 9. Collections is a named future track (no number until -scoped): - -| # | Track | -|---|---| -| 3 | Async capture jobs | -| **4** | **Auth foundation (this spec)** | -| **5** | **User management** | -| **6** | **Permissions & visibility (collection model)** | -| **7** | **Settings** | -| **8** | **Collections UI** | -| 9 | Cloud backup (was 4) | -| 10 | Cloud storage (was 5) |