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

1977 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Auth Foundation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add cookie-session + API-token authentication, a role bitmask system, a first-run setup wizard, and auth-protected routes to the Archivr server.
**Architecture:** Auth state lives in a dedicated `archivr-auth.sqlite` file next to the server config, separate from the per-archive store DBs. `AppState` gains an `auth_db_path`. Route handlers call `database::open_auth_db()` to get a connection; a new `AuthUser` Axum extractor validates session cookies and Bearer tokens for every protected handler.
**Tech Stack:** Rust/Axum, rusqlite (existing), argon2 (Argon2id hashing), rand (token generation), axum-extra (cookie extraction), React/Vite (existing frontend)
**Spec:** `docs/superpowers/specs/2026-06-25-auth-foundation-design.md`
---
## File Map
| File | Action | What changes |
|---|---|---|
| `Cargo.toml` | Modify | Add `argon2`, `rand`, `axum-extra` to workspace deps |
| `crates/archivr-server/Cargo.toml` | Modify | Pull `argon2`, `rand`, `axum-extra` from workspace |
| `crates/archivr-core/src/database.rs` | Modify | `initialize_auth_schema`, auth CRUD helpers, new record types |
| `crates/archivr-server/src/auth.rs` | **Create** | `AuthUser` extractor, password helpers, token generation, role constants |
| `crates/archivr-server/src/routes.rs` | Modify | `AppState` + `app()` gain `auth_db_path`; auth endpoints; route protection; `ApiError` gets JSON body + `unauthorized`/`forbidden` constructors |
| `crates/archivr-server/src/registry.rs` | Modify | `ServerRegistry` gains optional `auth_db_path` |
| `crates/archivr-server/src/main.rs` | Modify | Compute auth DB path; pass to `app()`; session cleanup task; remove non-loopback auth warning |
| `frontend/src/App.jsx` | Modify | Setup check on mount; `AuthContext`; 401 handling |
| `frontend/src/api.js` | Modify | 401 interceptor; auth helper calls |
| `frontend/src/components/LoginPage.jsx` | **Create** | Login form |
| `frontend/src/components/SetupPage.jsx` | **Create** | First-run owner creation wizard |
| `frontend/src/components/Topbar.jsx` | Modify | User menu + logout button |
---
## Task 1: Add dependencies
**Files:**
- Modify: `Cargo.toml`
- Modify: `crates/archivr-server/Cargo.toml`
- [ ] **Step 1: Add workspace dependencies**
Open `Cargo.toml`. In `[workspace.dependencies]`, add after the `base64` line:
```toml
argon2 = { version = "0.5", features = ["std"] }
rand = { version = "0.8", features = ["std"] }
axum-extra = { version = "0.9", features = ["cookie"] }
```
- [ ] **Step 2: Pull into server crate**
Open `crates/archivr-server/Cargo.toml`. Add to `[dependencies]`:
```toml
argon2.workspace = true
rand.workspace = true
axum-extra.workspace = true
```
- [ ] **Step 3: Verify compilation**
```bash
cd ~/personal/archivr && cargo check -p archivr-server
```
Expected: compiles with no errors.
- [ ] **Step 4: Commit**
```bash
git add Cargo.toml crates/archivr-server/Cargo.toml Cargo.lock
git commit -m "feat(auth): add argon2, rand, axum-extra dependencies"
```
---
## Task 2: Auth schema in `database.rs`
Add `initialize_auth_schema` and update `instance_settings`.
**Files:**
- Modify: `crates/archivr-core/src/database.rs`
- [ ] **Step 1: Write failing test for role seeding**
At the bottom of the `#[cfg(test)]` block in `database.rs`, add:
```rust
#[test]
fn auth_schema_seeds_builtin_roles() {
let conn = Connection::open_in_memory().unwrap();
initialize_auth_schema(&conn).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM roles WHERE is_builtin = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 4);
let owner_bits: i64 = conn
.query_row("SELECT bit_position FROM roles WHERE slug = 'owner'", [], |r| r.get(0))
.unwrap();
assert_eq!(owner_bits, 3);
}
#[test]
fn auth_schema_is_idempotent() {
let conn = Connection::open_in_memory().unwrap();
initialize_auth_schema(&conn).unwrap();
initialize_auth_schema(&conn).unwrap(); // must not panic
}
```
- [ ] **Step 2: Run to confirm they fail**
```bash
cd ~/personal/archivr && cargo test -p archivr-core auth_schema 2>&1 | tail -5
```
Expected: FAILED — `initialize_auth_schema` not found.
- [ ] **Step 3: Add `initialize_auth_schema`**
After the closing `}` of `initialize_schema` in `database.rs`, add:
```rust
pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY,
role_uid TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
level INTEGER NOT NULL,
bit_position INTEGER NOT NULL UNIQUE,
is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1))
);
INSERT OR IGNORE INTO roles (role_uid, slug, name, level, bit_position, is_builtin) VALUES
('role-guest', 'guest', 'Guest', 0, 0, 1),
('role-user', 'user', 'User', 1, 1, 1),
('role-admin', 'admin', 'Admin', 3, 2, 1),
('role-owner', 'owner', 'Owner', 4, 3, 1);
CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id),
assigned_at TEXT NOT NULL,
assigned_by_user_id INTEGER REFERENCES users(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY,
session_uid TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_bits INTEGER NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY,
token_uid TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
expires_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE TABLE IF NOT EXISTS instance_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
default_entry_visibility INTEGER NOT NULL DEFAULT 2
);
INSERT OR IGNORE INTO instance_settings
(id, public_index_enabled, public_entry_content_enabled,
public_archive_submission_enabled, default_entry_visibility)
VALUES (1, 0, 0, 0, 2);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_uid TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
created_at TEXT NOT NULL,
last_login_at TEXT
);
"#,
)?;
Ok(())
}
```
Note: `users` is duplicated here with `CREATE TABLE IF NOT EXISTS` so the auth DB is self-contained without needing `initialize_schema`.
- [ ] **Step 4: Run tests**
```bash
cd ~/personal/archivr && cargo test -p archivr-core auth_schema 2>&1 | tail -5
```
Expected: 2 tests pass.
- [ ] **Step 5: Add `open_auth_db` function**
After `open_or_initialize`, add:
```rust
pub fn open_auth_db(auth_db_path: &Path) -> Result<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**
```bash
cd ~/personal/archivr && cargo test -p archivr-core 2>&1 | tail -3
```
Expected: all existing tests pass.
```bash
git add crates/archivr-core/src/database.rs
git commit -m "feat(auth): add initialize_auth_schema and open_auth_db"
```
---
## Task 3: User and role DB helpers
**Files:**
- Modify: `crates/archivr-core/src/database.rs`
- [ ] **Step 1: Add record types**
After the existing struct definitions (before `pub fn database_path`), add:
```rust
#[derive(Debug, Clone)]
pub struct AuthUserRecord {
pub id: i64,
pub user_uid: String,
pub username: String,
pub password_hash: String,
pub status: String,
}
#[derive(Debug, Clone)]
pub struct SessionRecord {
pub user_id: i64,
pub role_bits: u32,
pub last_seen_at: String,
pub session_uid: String,
}
#[derive(Debug, Clone)]
pub struct ApiTokenRecord {
pub token_uid: String,
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
}
```
- [ ] **Step 2: Write failing tests for user helpers**
In the `#[cfg(test)]` block, add:
```rust
fn make_auth_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
initialize_auth_schema(&conn).unwrap();
conn
}
#[test]
fn ensure_owner_exists_returns_false_when_no_owner() {
let conn = make_auth_conn();
assert!(!ensure_owner_exists(&conn).unwrap());
}
#[test]
fn create_owner_then_ensure_returns_true() {
let conn = make_auth_conn();
create_owner(&conn, "alice", "hashed_pw").unwrap();
assert!(ensure_owner_exists(&conn).unwrap());
}
#[test]
fn create_owner_assigns_cumulative_roles() {
let conn = make_auth_conn();
let user_id = create_owner(&conn, "alice", "hashed_pw").unwrap();
let bits = compute_role_bits(&conn, user_id).unwrap();
// guest=1, user=2, admin=4, owner=8 → 15
assert_eq!(bits, 15u32);
}
#[test]
fn get_user_by_username_returns_none_for_unknown() {
let conn = make_auth_conn();
assert!(get_user_by_username(&conn, "nobody").unwrap().is_none());
}
```
- [ ] **Step 3: Run to confirm they fail**
```bash
cd ~/personal/archivr && cargo test -p archivr-core ensure_owner 2>&1 | tail -5
```
Expected: FAILED.
- [ ] **Step 4: Implement user/role helpers**
After `open_auth_db`, add:
```rust
/// Returns true if an owner account exists.
pub fn ensure_owner_exists(conn: &Connection) -> Result<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**
```bash
cd ~/personal/archivr && cargo test -p archivr-core ensure_owner create_owner get_user compute_role 2>&1 | tail -5
```
Expected: all 4 new tests pass.
- [ ] **Step 6: Commit**
```bash
git add crates/archivr-core/src/database.rs
git commit -m "feat(auth): user and role DB helpers (create_owner, compute_role_bits)"
```
---
## Task 4: Session and token DB helpers
**Files:**
- Modify: `crates/archivr-core/src/database.rs`
- [ ] **Step 1: Write failing session tests**
```rust
#[test]
fn create_and_get_session() {
let conn = make_auth_conn();
let user_id = create_owner(&conn, "alice", "pw").unwrap();
let uid = create_session(&conn, user_id, 15, None).unwrap();
let sess = get_session(&conn, &uid).unwrap().unwrap();
assert_eq!(sess.user_id, user_id);
assert_eq!(sess.role_bits, 15);
}
#[test]
fn get_session_returns_none_for_unknown() {
let conn = make_auth_conn();
assert!(get_session(&conn, "nonexistent").unwrap().is_none());
}
#[test]
fn delete_session_removes_it() {
let conn = make_auth_conn();
let user_id = create_owner(&conn, "alice", "pw").unwrap();
let uid = create_session(&conn, user_id, 15, None).unwrap();
delete_session(&conn, &uid).unwrap();
assert!(get_session(&conn, &uid).unwrap().is_none());
}
#[test]
fn token_hash_round_trips() {
let conn = make_auth_conn();
let user_id = create_owner(&conn, "alice", "pw").unwrap();
create_api_token(&conn, user_id, "hash_abc", "My Token").unwrap();
let found_id = get_user_for_token(&conn, "hash_abc").unwrap();
assert_eq!(found_id, Some(user_id));
}
#[test]
fn get_user_for_token_returns_none_for_unknown() {
let conn = make_auth_conn();
assert!(get_user_for_token(&conn, "unknown").unwrap().is_none());
}
```
- [ ] **Step 2: Run to confirm failure**
```bash
cd ~/personal/archivr && cargo test -p archivr-core create_and_get_session token_hash 2>&1 | tail -5
```
- [ ] **Step 3: Implement session helpers**
After `compute_role_bits`, add:
```rust
/// Returns a new session_uid (UUID).
pub fn create_session(
conn: &Connection,
user_id: i64,
role_bits: u32,
user_agent: Option<&str>,
) -> Result<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**
```rust
/// Creates an API token. `token_hash` is SHA3-256 hex of the raw token.
/// Returns the token_uid.
pub fn create_api_token(
conn: &Connection,
user_id: i64,
token_hash: &str,
name: &str,
) -> Result<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**
```bash
cd ~/personal/archivr && cargo test -p archivr-core 2>&1 | tail -5
```
Expected: all tests pass (including all previously passing ones).
- [ ] **Step 6: Commit**
```bash
git add crates/archivr-core/src/database.rs
git commit -m "feat(auth): session and token DB helpers"
```
---
## Task 5: Auth DB path in AppState, registry, and main.rs
**Files:**
- Modify: `crates/archivr-server/src/registry.rs`
- Modify: `crates/archivr-server/src/routes.rs`
- Modify: `crates/archivr-server/src/main.rs`
- [ ] **Step 1: Add `auth_db_path` to `ServerRegistry`**
In `registry.rs`, update the `ServerRegistry` struct:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ServerRegistry {
#[serde(default)]
pub archives: Vec<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`:
```rust
#[derive(Clone)]
pub struct AppState {
registry: Arc<ServerRegistry>,
pub auth_db_path: Arc<std::path::PathBuf>,
}
```
Update `app()` signature and body:
```rust
pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router {
let state = AppState {
registry: Arc::new(registry),
auth_db_path: Arc::new(auth_db_path),
};
// ... rest unchanged
```
- [ ] **Step 3: Update all tests in `routes.rs` that call `app(registry)`**
Every `app(registry)` in the test module must become `app(registry, std::path::PathBuf::from("/tmp/test-auth.sqlite"))`.
Search for all occurrences:
```bash
grep -n "app(registry" ~/personal/archivr/crates/archivr-server/src/routes.rs | head -20
```
Update each one to `app(registry, tempfile::tempdir().unwrap().path().join("auth.sqlite"))`. Add `use tempfile;` if not present. (The `tempfile` crate is already a workspace dependency.)
- [ ] **Step 4: Update `main.rs`**
```rust
mod registry;
mod routes;
use anyhow::{Context, Result};
use std::{net::SocketAddr, path::PathBuf};
const DEFAULT_BIND: &str = "127.0.0.1:8080";
#[tokio::main]
async fn main() -> Result<()> {
let config_path = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
let registry = registry::load_registry(&config_path)?;
// Auth DB lives next to the config file unless overridden.
let auth_db_path = registry.auth_db_path.clone().unwrap_or_else(|| {
config_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("archivr-auth.sqlite")
});
let app = routes::app(registry.clone(), auth_db_path.clone());
let bind_str = std::env::var("ARCHIVR_BIND")
.ok()
.or_else(|| registry.bind.clone())
.unwrap_or_else(|| DEFAULT_BIND.to_string());
let addr: SocketAddr = bind_str
.parse()
.with_context(|| format!("invalid bind address: {bind_str}"))?;
let listener = tokio::net::TcpListener::bind(addr).await?;
println!("archivr-server listening on http://{addr}");
axum::serve(listener, app).await?;
Ok(())
}
```
- [ ] **Step 5: Run full test suite**
```bash
cd ~/personal/archivr && cargo test 2>&1 | tail -5
```
Expected: all tests pass.
- [ ] **Step 6: Commit**
```bash
git add crates/archivr-server/src/routes.rs \
crates/archivr-server/src/registry.rs \
crates/archivr-server/src/main.rs
git commit -m "feat(auth): add auth_db_path to AppState, registry, and main.rs"
```
---
## Task 6: Create `auth.rs` — AuthUser extractor
**Files:**
- Create: `crates/archivr-server/src/auth.rs`
- Modify: `crates/archivr-server/src/routes.rs` (add `mod auth; use auth::AuthUser;`)
- [ ] **Step 1: Create `auth.rs`**
Create `crates/archivr-server/src/auth.rs` with the following content:
```rust
use anyhow::Result;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::{SaltString, rand_core::OsRng};
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use axum_extra::extract::CookieJar;
use rand::RngCore;
use crate::routes::{ApiError, AppState};
use archivr_core::database;
// ── Role bit constants ────────────────────────────────────────────────────────
pub const ROLE_GUEST: u32 = 1; // bit 0
pub const ROLE_USER: u32 = 2; // bit 1
pub const ROLE_ADMIN: u32 = 4; // bit 2
pub const ROLE_OWNER: u32 = 8; // bit 3
// ── AuthUser ─────────────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub enum AuthUser {
Guest,
Authenticated { user_id: i64, role_bits: u32 },
}
impl AuthUser {
/// Returns (user_id, role_bits) or 401 if Guest.
pub fn require_auth(&self) -> Result<(i64, u32), ApiError> {
match self {
AuthUser::Authenticated { user_id, role_bits } => Ok((*user_id, *role_bits)),
AuthUser::Guest => Err(ApiError::unauthorized("login required")),
}
}
/// Returns Ok(()) if the user has the given role bit set, else 401/403.
pub fn require_role(&self, bit: u32) -> Result<(), ApiError> {
match self {
AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0 => Ok(()),
AuthUser::Authenticated { .. } => Err(ApiError::forbidden("insufficient permissions")),
AuthUser::Guest => Err(ApiError::unauthorized("login required")),
}
}
pub fn has_role(&self, bit: u32) -> bool {
matches!(self, AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0)
}
}
#[async_trait]
impl FromRequestParts<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:
```rust
mod auth;
pub use auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER};
```
Also add `unauthorized` and `forbidden` constructors to `ApiError`, and update `IntoResponse` to return JSON:
```rust
impl ApiError {
// ... existing constructors ...
pub fn unauthorized(message: &str) -> Self {
Self { status: StatusCode::UNAUTHORIZED, message: message.to_string() }
}
pub fn forbidden(message: &str) -> Self {
Self { status: StatusCode::FORBIDDEN, message: message.to_string() }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = serde_json::json!({ "error": self.message });
(self.status, axum::Json(body)).into_response()
}
}
```
- [ ] **Step 3: Compile check**
```bash
cd ~/personal/archivr && cargo check -p archivr-server 2>&1 | tail -10
```
Fix any import errors. Common fix: add `use std::path::Path;` or check `axum_extra` cookie import.
- [ ] **Step 4: Write extractor tests in `routes.rs`**
In the `#[cfg(test)]` block in `routes.rs`, add helpers and tests:
```rust
fn make_test_app() -> (Router, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let auth_db_path = dir.path().join("auth.sqlite");
let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None };
(app(registry, auth_db_path), dir)
}
#[tokio::test]
async fn health_check_returns_ok() {
let (app, _dir) = make_test_app();
let response = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
```
Update the existing `archives_endpoint_lists_mounted_archives` test to use `make_test_app()`:
```rust
#[tokio::test]
async fn archives_endpoint_lists_mounted_archives() {
let dir = tempfile::tempdir().unwrap();
let auth_db_path = dir.path().join("auth.sqlite");
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "personal".to_string(),
label: "Personal".to_string(),
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
}],
bind: None,
auth_db_path: None,
};
let response = app(registry, auth_db_path)
.oneshot(Request::builder().uri("/api/archives").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
```
Apply the same pattern to **every other test** in the module that calls `app(registry)`.
- [ ] **Step 5: Run all tests**
```bash
cd ~/personal/archivr && cargo test 2>&1 | tail -5
```
Expected: all tests pass.
- [ ] **Step 6: Commit**
```bash
git add crates/archivr-server/src/auth.rs crates/archivr-server/src/routes.rs
git commit -m "feat(auth): AuthUser extractor, password helpers, token generation"
```
---
## Task 7: Auth endpoints — login, logout, /me, setup
**Files:**
- Modify: `crates/archivr-server/src/routes.rs`
Add the following to `routes.rs`.
- [ ] **Step 1: Write failing tests for auth endpoints**
```rust
#[tokio::test]
async fn setup_required_before_owner_created() {
let (app, _dir) = make_test_app();
let response = app
.oneshot(
Request::builder()
.uri("/api/auth/setup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["setup_required"], true);
}
#[tokio::test]
async fn login_with_wrong_password_returns_401() {
let dir = tempfile::tempdir().unwrap();
let auth_db_path = dir.path().join("auth.sqlite");
// Seed owner
{
let conn = archivr_core::database::open_auth_db(&auth_db_path).unwrap();
let hash = crate::auth::hash_password("correct").unwrap();
archivr_core::database::create_owner(&conn, "owner", &hash).unwrap();
}
let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None };
let response = app(registry, auth_db_path)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.body(Body::from(r#"{"username":"owner","password":"wrong"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn setup_post_creates_owner_and_returns_409_on_repeat() {
let (app_once, dir) = make_test_app();
let auth_db_path = dir.path().join("auth.sqlite");
let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None };
// First POST creates the owner
let response = app_once
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(Body::from(r#"{"username":"owner","password":"hunter2"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
// Second POST must return 409
let app2 = app(registry, auth_db_path);
let response2 = app2
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(Body::from(r#"{"username":"owner2","password":"hunter2"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response2.status(), StatusCode::CONFLICT);
}
```
- [ ] **Step 2: Run to confirm failure**
```bash
cd ~/personal/archivr && cargo test -p archivr-server setup_required login_with_wrong 2>&1 | tail -10
```
Expected: FAILED.
- [ ] **Step 3: Add auth routes to `app()` in `routes.rs`**
In the `app()` function, add these routes before `.with_state(state)`:
```rust
.route("/api/auth/setup", get(auth_setup_status).post(auth_setup))
.route("/api/auth/login", post(auth_login))
.route("/api/auth/logout", post(auth_logout))
.route("/api/auth/me", get(auth_me))
// Setup guard must come AFTER routes are defined and BEFORE .with_state
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
```
- [ ] **Step 3b: Implement `setup_guard` middleware**
Add this function before the `app()` definition:
```rust
/// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed.
async fn setup_guard(
State(state): State<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:
```rust
#[derive(Debug, serde::Deserialize)]
struct LoginBody {
username: String,
password: String,
}
#[derive(Debug, serde::Deserialize)]
struct SetupBody {
username: String,
password: String,
}
```
- [ ] **Step 5: Implement handler functions**
Add after the existing handlers:
```rust
async fn auth_setup_status(
State(state): State<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**
```bash
cd ~/personal/archivr && cargo test -p archivr-server 2>&1 | tail -5
```
Expected: all tests pass.
- [ ] **Step 7: Commit**
```bash
git add crates/archivr-server/src/routes.rs
git commit -m "feat(auth): login, logout, /me, setup endpoints"
```
---
## Task 8: API token endpoints
**Files:**
- Modify: `crates/archivr-server/src/routes.rs`
- [ ] **Step 1: Write failing test**
```rust
#[tokio::test]
async fn create_token_requires_auth() {
let (app, _dir) = make_test_app();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/tokens")
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"my token"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
```
- [ ] **Step 2: Add token routes to `app()`**
```rust
.route("/api/auth/tokens", get(list_tokens).post(create_token))
.route("/api/auth/tokens/:token_uid", delete(delete_token))
```
- [ ] **Step 3: Add request type**
```rust
#[derive(Debug, serde::Deserialize)]
struct CreateTokenBody {
name: String,
}
```
- [ ] **Step 4: Implement token handlers**
```rust
async fn create_token(
State(state): State<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:
```rust
#[derive(Debug, Clone, serde::Serialize)]
pub struct ApiTokenRecord { ... }
```
- [ ] **Step 6: Run tests**
```bash
cd ~/personal/archivr && cargo test -p archivr-server 2>&1 | tail -5
```
Expected: all pass including `create_token_requires_auth`.
- [ ] **Step 7: Commit**
```bash
git add crates/archivr-server/src/routes.rs crates/archivr-core/src/database.rs
git commit -m "feat(auth): API token endpoints (create, list, delete)"
```
---
## Task 9: Route protection for existing routes
**Files:**
- Modify: `crates/archivr-server/src/routes.rs`
- [ ] **Step 1: Write failing tests**
```rust
#[tokio::test]
async fn capture_returns_401_for_unauthenticated() {
let (app, _dir) = make_test_app();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/captures")
.header("content-type", "application/json")
.body(Body::from(r#"{"locator":"https://example.com"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
```
- [ ] **Step 2: Run to confirm failure**
```bash
cd ~/personal/archivr && cargo test -p archivr-server capture_returns_401 2>&1 | tail -5
```
Expected: FAILED (currently returns 200 or 404).
- [ ] **Step 3: Add `AuthUser` to WRITE handlers**
Update `capture_handler` to require auth:
```rust
async fn capture_handler(
State(state): State<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:
```rust
// ── Security Boundary ──────────────────────────────────────────────────────
//
// Route protection tiers:
// STATIC — no auth: GET /, GET /assets/*
// PUBLIC_READ — no auth (visibility filtering deferred to Track 6):
// GET /api/archives, GET /api/archives/:id/entries, etc.
// AUTH — requires login (ROLE_USER bit):
// POST /api/archives/:id/captures
// POST/PUT/DELETE /api/archives/:id/tags
// POST/DELETE /api/archives/:id/entries/:uid/tags
// ADMIN — requires ROLE_ADMIN:
// GET /api/admin/archives (future)
// OWNER — requires ROLE_OWNER:
// instance settings (future)
// AUTH_SELF — no role guard (own resources):
// GET/POST/DELETE /api/auth/tokens
// POST /api/auth/logout
// GET /api/auth/me
// ────────────────────────────────────────────────────────────────────────────
```
- [ ] **Step 5: Run all tests**
```bash
cd ~/personal/archivr && cargo test 2>&1 | tail -5
```
Expected: all pass.
- [ ] **Step 6: Commit**
```bash
git add crates/archivr-server/src/routes.rs
git commit -m "feat(auth): apply ROLE_USER guard to WRITE routes"
```
---
## Task 10: Session cleanup background task
**Files:**
- Modify: `crates/archivr-server/src/main.rs`
- [ ] **Step 1: Add cleanup task to `main.rs`**
After the `app` binding and before `let listener`, add:
```rust
// Spawn session cleanup background task: delete expired sessions at startup
// and every 24 hours.
let cleanup_auth_path = auth_db_path.clone();
tokio::spawn(async move {
loop {
if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) {
match archivr_core::database::delete_expired_sessions(&conn) {
Ok(n) if n > 0 => eprintln!("info: cleaned up {n} expired sessions"),
Err(e) => eprintln!("warn: session cleanup failed: {e:#}"),
_ => {}
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await;
}
});
```
Add `use archivr_core;` at the top if not already present (it's brought in transitively via routes).
- [ ] **Step 2: Verify compilation**
```bash
cd ~/personal/archivr && cargo check -p archivr-server 2>&1 | tail -5
```
Expected: compiles cleanly.
- [ ] **Step 3: Commit**
```bash
git add crates/archivr-server/src/main.rs
git commit -m "feat(auth): session cleanup background task (24h interval)"
```
---
## Task 11: Frontend — auth state, api.js, LoginPage, SetupPage
**Files:**
- Modify: `frontend/src/App.jsx`
- Modify: `frontend/src/api.js`
- Create: `frontend/src/components/LoginPage.jsx`
- Create: `frontend/src/components/SetupPage.jsx`
- [ ] **Step 1: Update `api.js`**
Open `frontend/src/api.js`. Add auth helper functions and the 401 interceptor at the bottom of the file (replace or append to the existing `export` structure):
```js
// ── Auth helpers ─────────────────────────────────────────────────────────────
export async function checkSetup() {
const r = await fetch('/api/auth/setup');
const data = await r.json();
return data.setup_required === true;
}
export async function doSetup(username, password) {
const r = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!r.ok) throw new Error((await r.json()).error || 'Setup failed');
return r.json();
}
export async function login(username, password) {
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!r.ok) throw new Error((await r.json()).error || 'Login failed');
return r.json(); // { user_uid, username, role_bits }
}
export async function logout() {
await fetch('/api/auth/logout', { method: 'POST' });
}
export async function fetchMe() {
const r = await fetch('/api/auth/me');
if (r.status === 401) return null;
return r.json();
}
// ── 401 interceptor ───────────────────────────────────────────────────────────
// Wrap fetch so any 401 dispatches a custom event for App.jsx to handle.
const _origFetch = window.fetch;
window.fetch = async (...args) => {
const r = await _origFetch(...args);
if (r.status === 401) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
// Don't intercept auth endpoints themselves
if (!url.includes('/api/auth/')) {
window.dispatchEvent(new CustomEvent('auth:expired'));
}
}
return r;
};
```
- [ ] **Step 2: Create `LoginPage.jsx`**
```jsx
import { useState } from 'react';
import { login } from '../api.js';
export default function LoginPage({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const user = await login(username, password);
onLogin(user);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<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`**
```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):
```jsx
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { checkSetup, fetchMe, logout as apiLogout } from './api.js';
import LoginPage from './components/LoginPage.jsx';
import SetupPage from './components/SetupPage.jsx';
export const AuthContext = createContext(null);
```
Inside the `App` function, add state and effects at the top:
```jsx
const [authState, setAuthState] = useState('loading'); // 'loading' | 'setup' | 'login' | 'authenticated'
const [currentUser, setCurrentUser] = useState(null);
useEffect(() => {
(async () => {
const needsSetup = await checkSetup();
if (needsSetup) { setAuthState('setup'); return; }
const user = await fetchMe();
if (!user) { setAuthState('login'); return; }
setCurrentUser(user);
setAuthState('authenticated');
})();
}, []);
// Listen for 401s from the fetch interceptor in api.js
useEffect(() => {
const handler = () => { setCurrentUser(null); setAuthState('login'); };
window.addEventListener('auth:expired', handler);
return () => window.removeEventListener('auth:expired', handler);
}, []);
```
In the `App` return, add guards at the very top of the JSX:
```jsx
if (authState === 'loading') return <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:
```jsx
return (
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
{/* existing app JSX */}
</AuthContext.Provider>
);
```
- [ ] **Step 5: Build to check for errors**
```bash
cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -10
```
Expected: builds successfully, no type errors.
- [ ] **Step 6: Commit**
```bash
cd ~/personal/archivr
git add frontend/src/App.jsx frontend/src/api.js \
frontend/src/components/LoginPage.jsx \
frontend/src/components/SetupPage.jsx
git commit -m "feat(auth): frontend login/setup pages + auth state in App.jsx"
```
---
## Task 12: Topbar — user menu and logout button
**Files:**
- Modify: `frontend/src/components/Topbar.jsx`
- [ ] **Step 1: Update `Topbar.jsx`**
Open `frontend/src/components/Topbar.jsx`. Import `AuthContext` and `logout`:
```jsx
import { useContext, useState } from 'react';
import { AuthContext } from '../App.jsx';
import { logout as apiLogout } from '../api.js';
```
Inside the component, add:
```jsx
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {};
const [loggingOut, setLoggingOut] = useState(false);
async function handleLogout() {
setLoggingOut(true);
await apiLogout();
setCurrentUser(null);
window.location.reload(); // simplest way to reset all state
}
```
In the JSX, add a user menu at the end of the topbar:
```jsx
{currentUser && (
<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**
```bash
cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -5
```
Expected: clean build.
- [ ] **Step 3: Commit**
```bash
cd ~/personal/archivr
git add frontend/src/components/Topbar.jsx
git commit -m "feat(auth): user menu and logout button in Topbar"
```
---
## Task 13: Update NEXT.md
**Files:**
- Modify: `NEXT.md`
- [ ] **Step 1: Add Track 4 as done, renumber remaining tracks**
Open `NEXT.md`. Add a new Track 4 section (done) after Track 3, following the same ~~strikethrough~~ ✅ pattern as Tracks 1 and 2. Rename the old Track 4 (Cloud backup) to Track 9 and Track 5 (Cloud storage) to Track 10. Add stub sections for new Tracks 5, 6, 7, 8 with brief descriptions and "pending" status.
Use this numbering:
| # | Track | Status |
|---|---|---|
| 1 | Generic URL capture | Done |
| 2 | Web page archiving | Done |
| 3 | Async capture jobs | Pending |
| **4** | **Auth foundation** | **Done (this track)** |
| 5 | User management | Pending |
| 6 | Permissions & visibility | Pending |
| 7 | Settings | Pending |
| 8 | Collections UI | Pending |
| 9 | Cloud backup | Pending (was 4) |
| 10 | Cloud storage | Pending (was 5) |
- [ ] **Step 2: Commit**
```bash
git add NEXT.md
git commit -m "docs: mark Track 4 auth foundation done, renumber roadmap tracks"
```
---
## Final verification
- [ ] **Run the full test suite**
```bash
cd ~/personal/archivr && cargo test 2>&1 | tail -5
```
Expected: all tests pass (count will be higher than the pre-implementation baseline due to new tests).
- [ ] **Build the frontend**
```bash
cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -5
```
Expected: builds cleanly.