60 KiB
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_dbfunction
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_pathtoServerRegistry
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
AppStateinroutes.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.rsthat callapp(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(addmod 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()inroutes.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_guardmiddleware
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::SerializeonApiTokenRecord
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
AuthUserto 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 1–23). 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.