1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00
archivr/docs/superpowers/plans/2026-06-25-auth-foundation.md

60 KiB
Raw Blame History

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:

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]:

argon2.workspace = true
rand.workspace = true
axum-extra.workspace = true
  • Step 3: Verify compilation
cd ~/personal/archivr && cargo check -p archivr-server

Expected: compiles with no errors.

  • Step 4: Commit
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:

#[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
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:

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
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:

pub fn open_auth_db(auth_db_path: &Path) -> Result<Connection> {
    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
cd ~/personal/archivr && cargo test -p archivr-core 2>&1 | tail -3

Expected: all existing tests pass.

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:

#[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<String>,
}
  • Step 2: Write failing tests for user helpers

In the #[cfg(test)] block, add:

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
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:

/// Returns true if an owner account exists.
pub fn ensure_owner_exists(conn: &Connection) -> Result<bool> {
    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<i64> {
    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<Option<AuthUserRecord>> {
    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<u32> {
    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
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
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

#[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
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:

/// Returns a new session_uid (UUID).
pub fn create_session(
    conn: &Connection,
    user_id: i64,
    role_bits: u32,
    user_agent: Option<&str>,
) -> Result<String> {
    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<Option<SessionRecord>> {
    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<usize> {
    let now = now_timestamp();
    let n = conn.execute("DELETE FROM sessions WHERE expires_at <= ?1", [now])?;
    Ok(n)
}
  • Step 4: Implement token helpers
/// 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<String> {
    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<Option<i64>> {
    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<bool> {
    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<Vec<ApiTokenRecord>> {
    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::<Result<Vec<_>, _>>()?;
    Ok(records)
}
  • Step 5: Run all new tests
cd ~/personal/archivr && cargo test -p archivr-core 2>&1 | tail -5

Expected: all tests pass (including all previously passing ones).

  • Step 6: Commit
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:

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ServerRegistry {
    #[serde(default)]
    pub archives: Vec<MountedArchive>,
    /// Optional bind address. Defaults to `127.0.0.1:8080`.
    #[serde(default)]
    pub bind: Option<String>,
    /// 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<std::path::PathBuf>,
}
  • Step 2: Update AppState in routes.rs

Change AppState:

#[derive(Clone)]
pub struct AppState {
    registry: Arc<ServerRegistry>,
    pub auth_db_path: Arc<std::path::PathBuf>,
}

Update app() signature and body:

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:

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
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
cd ~/personal/archivr && cargo test 2>&1 | tail -5

Expected: all tests pass.

  • Step 6: Commit
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:

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<AppState> for AuthUser {
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, std::convert::Infallible> {
        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::<chrono::Utc>::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<String> {
    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<bool> {
    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:

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:

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
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:

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():

#[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
cd ~/personal/archivr && cargo test 2>&1 | tail -5

Expected: all tests pass.

  • Step 6: Commit
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
#[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
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):

.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:

/// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed.
async fn setup_guard(
    State(state): State<AppState>,
    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:

#[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:

async fn auth_setup_status(
    State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
    Json(body): Json<SetupBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), 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<AppState>,
    headers: axum::http::HeaderMap,
    Json(body): Json<LoginBody>,
) -> Result<(StatusCode, axum::http::HeaderMap, Json<serde_json::Value>), 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<AppState>,
    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<AppState>,
    auth_user: AuthUser,
) -> Result<Json<serde_json::Value>, 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
cd ~/personal/archivr && cargo test -p archivr-server 2>&1 | tail -5

Expected: all tests pass.

  • Step 7: Commit
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

#[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()
.route("/api/auth/tokens", get(list_tokens).post(create_token))
.route("/api/auth/tokens/:token_uid", delete(delete_token))
  • Step 3: Add request type
#[derive(Debug, serde::Deserialize)]
struct CreateTokenBody {
    name: String,
}
  • Step 4: Implement token handlers
async fn create_token(
    State(state): State<AppState>,
    auth_user: AuthUser,
    Json(body): Json<CreateTokenBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), 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<AppState>,
    auth_user: AuthUser,
) -> Result<Json<Vec<database::ApiTokenRecord>>, 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<AppState>,
    auth_user: AuthUser,
    Path(token_uid): Path<String>,
) -> Result<StatusCode, ApiError> {
    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:

#[derive(Debug, Clone, serde::Serialize)]
pub struct ApiTokenRecord { ... }
  • Step 6: Run tests
cd ~/personal/archivr && cargo test -p archivr-server 2>&1 | tail -5

Expected: all pass including create_token_requires_auth.

  • Step 7: Commit
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

#[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
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:

async fn capture_handler(
    State(state): State<AppState>,
    auth_user: AuthUser,
    Path(archive_id): Path<String>,
    Json(body): Json<CaptureBody>,
) -> Result<Json<capture::CaptureResult>, 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 123). Replace the route classification list to reflect the new protection:

// ── 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
cd ~/personal/archivr && cargo test 2>&1 | tail -5

Expected: all pass.

  • Step 6: Commit
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:

// 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
cd ~/personal/archivr && cargo check -p archivr-server 2>&1 | tail -5

Expected: compiles cleanly.

  • Step 3: Commit
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):

// ── 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
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 (
    <div className="login-page">
      <h1>Archivr</h1>
      <form onSubmit={handleSubmit}>
        <label>
          Username
          <input
            type="text"
            value={username}
            onChange={e => setUsername(e.target.value)}
            autoFocus
            required
          />
        </label>
        <label>
          Password
          <input
            type="password"
            value={password}
            onChange={e => setPassword(e.target.value)}
            required
          />
        </label>
        {error && <p className="error">{error}</p>}
        <button type="submit" disabled={loading}>
          {loading ? 'Logging in…' : 'Log in'}
        </button>
      </form>
    </div>
  );
}
  • Step 3: Create SetupPage.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 (
    <div className="setup-page">
      <h1>Welcome to Archivr</h1>
      <p>Create your owner account to get started.</p>
      <form onSubmit={handleSubmit}>
        <label>
          Username
          <input
            type="text"
            value={username}
            onChange={e => setUsername(e.target.value)}
            autoFocus
            required
          />
        </label>
        <label>
          Password
          <input
            type="password"
            value={password}
            onChange={e => setPassword(e.target.value)}
            required
          />
        </label>
        <label>
          Confirm password
          <input
            type="password"
            value={confirm}
            onChange={e => setConfirm(e.target.value)}
            required
          />
        </label>
        {error && <p className="error">{error}</p>}
        <button type="submit" disabled={loading}>
          {loading ? 'Creating account…' : 'Create account'}
        </button>
      </form>
    </div>
  );
}
  • Step 4: Update App.jsx

Open frontend/src/App.jsx. Add the following at the top (after existing imports):

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:

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:

if (authState === 'loading') return <div>Loading</div>;
if (authState === 'setup')   return <SetupPage onComplete={() => setAuthState('login')} />;
if (authState === 'login')   return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;

Wrap the existing return content in:

return (
  <AuthContext.Provider value={{ currentUser, setCurrentUser }}>
    {/* existing app JSX */}
  </AuthContext.Provider>
);
  • Step 5: Build to check for errors
cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -10

Expected: builds successfully, no type errors.

  • Step 6: Commit
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:

import { useContext, useState } from 'react';
import { AuthContext } from '../App.jsx';
import { logout as apiLogout } from '../api.js';

Inside the component, add:

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:

{currentUser && (
  <div className="user-menu">
    <span className="username">{currentUser.username}</span>
    <button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
      {loggingOut ? 'Logging out…' : 'Log out'}
    </button>
  </div>
)}
  • Step 2: Build to check for errors
cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -5

Expected: clean build.

  • Step 3: Commit
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
git add NEXT.md
git commit -m "docs: mark Track 4 auth foundation done, renumber roadmap tracks"

Final verification

  • Run the full test suite
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
cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -5

Expected: builds cleanly.