Mercury/docs/superpowers/plans/2026-06-16-mercury-implementation.md
Matthew L McPeak 05cb37730d
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 3m1s
ci / publish (push) Successful in 2m38s
Initial Commit
2026-06-17 16:21:16 -04:00

93 KiB

Mercury 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: Build Mercury — a monolithic Rust API with dynamic CRUD, a JWT bitmask permission system, a query registry cache, an admin suite, and a Vue 3 frontend — runnable via a single docker compose up.

Architecture: Axum handles HTTP routing behind two middleware layers: a glob-based blacklist check and a JWT permission-bit validator. The CRUD engine auto-generates parameterized SQL from route + HTTP method, caches templates in a DashMap with TTI eviction, and persists named queries in PostgreSQL. Vue 3 is built by Vite and served as static files by tower-http::ServeDir.

Tech Stack: Rust 1.75+, Axum 0.7, SQLx 0.7, PostgreSQL 16, DashMap 5, glob 0.3, jsonwebtoken 9, bcrypt 0.15, Vue 3, Vite, @nychthemeron/library, tower-http 0.5, Docker multi-stage build.


File Map

Mercury/
  Cargo.toml
  .env.example
  Dockerfile
  docker-compose.yml
  README.md
  src/
    main.rs                        startup: config, pool, caches, router, static files
    config.rs                      Config struct from env vars
    state.rs                       AppState, QueryCache, BlacklistCache types
    auth/
      mod.rs                       JWT Claims, encode/decode helpers
      middleware.rs                 RequirePermission extractor
    cache/
      mod.rs                       pub use re-exports
      query_cache.rs               DashMap + sweep task + hit/miss counters
      blacklist_cache.rs           Arc<RwLock<Vec<CompiledEntry>>> + reload fn
    db/
      mod.rs                       pub use re-exports
      pool.rs                      PgPool init + run migrations
      migrations/
        001_initial.sql            schema, seed permissions, seed blacklist
    models/
      mod.rs                       pub use re-exports
      user.rs                      User, CreateUser, UpdateUser
      permission.rs                Permission, CreatePermission, UpdatePermission
      query.rs                     StoredQuery, CreateQuery, UpdateQuery
      blacklist.rs                 BlacklistEntry, CreateBlacklistEntry, UpdateBlacklistEntry
    routes/
      mod.rs                       pub use re-exports
      auth.rs                      POST /auth/login
      crud.rs                      generic /api/{table} + query builder + row→JSON
      admin/
        mod.rs                     admin Router assembly
        queries.rs                 query registry CRUD
        cache.rs                   cache stats + flush
        users.rs                   user management + grant/revoke
        permissions.rs             permission definitions CRUD
        blacklist.rs               blacklist CRUD + trigger reload
  ui/
    .npmrc
    package.json
    vite.config.ts
    src/
      main.ts
      App.vue
      router/index.ts
      stores/auth.ts
      views/
        Login.vue
        admin/
          Layout.vue
          Queries.vue
          Users.vue
          Permissions.vue
          Blacklist.vue
          Cache.vue
  tests/
    integration/
      auth_test.rs
      crud_test.rs
      admin_test.rs

Task 1: Cargo.toml + project skeleton

Files:

  • Create: Cargo.toml

  • Create: src/main.rs (stub)

  • Step 1: Write Cargo.toml

[package]
name = "mercury"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "mercury"
path = "src/main.rs"

[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
bcrypt = "0.15"
dashmap = "5"
glob = "0.3"
tower-http = { version = "0.5", features = ["fs", "cors"] }
tower = { version = "0.4", features = ["util"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[dev-dependencies]
tower = { version = "0.4", features = ["util"] }
http-body-util = "0.1"
  • Step 2: Write src/main.rs stub
#[tokio::main]
async fn main() {
    println!("Mercury starting...");
}
  • Step 3: Verify it compiles
cargo build

Expected: Compiling mercury v0.1.0 then Finished.

  • Step 4: Commit
git add Cargo.toml src/main.rs
git commit -m "chore: init Mercury project scaffold"

Task 2: Config

Files:

  • Create: src/config.rs

  • Create: .env.example

  • Step 1: Write failing test

In src/config.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_defaults() {
        std::env::set_var("DATABASE_URL", "postgres://test");
        std::env::set_var("JWT_SECRET", "secret");
        let cfg = Config::from_env().unwrap();
        assert_eq!(cfg.jwt_expiry_secs, 3600);
        assert_eq!(cfg.cache_max_capacity, 10_000);
        assert_eq!(cfg.cache_idle_timeout_secs, 300);
        assert_eq!(cfg.cache_sweep_interval_secs, 60);
    }
}
  • Step 2: Run test — expect compile failure
cargo test config

Expected: error[E0433]: failed to resolve: use of undeclared crate or module

  • Step 3: Implement Config
use anyhow::Result;

#[derive(Clone, Debug)]
pub struct Config {
    pub database_url: String,
    pub jwt_secret: String,
    pub jwt_expiry_secs: u64,
    pub cache_max_capacity: usize,
    pub cache_idle_timeout_secs: u64,
    pub cache_sweep_interval_secs: u64,
}

impl Config {
    pub fn from_env() -> Result<Self> {
        Ok(Self {
            database_url: std::env::var("DATABASE_URL")?,
            jwt_secret: std::env::var("JWT_SECRET")?,
            jwt_expiry_secs: std::env::var("JWT_EXPIRY_SECS")
                .unwrap_or_else(|_| "3600".into())
                .parse()?,
            cache_max_capacity: std::env::var("CACHE_MAX_CAPACITY")
                .unwrap_or_else(|_| "10000".into())
                .parse()?,
            cache_idle_timeout_secs: std::env::var("CACHE_IDLE_TIMEOUT_SECS")
                .unwrap_or_else(|_| "300".into())
                .parse()?,
            cache_sweep_interval_secs: std::env::var("CACHE_SWEEP_INTERVAL_SECS")
                .unwrap_or_else(|_| "60".into())
                .parse()?,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_defaults() {
        std::env::set_var("DATABASE_URL", "postgres://test");
        std::env::set_var("JWT_SECRET", "secret");
        let cfg = Config::from_env().unwrap();
        assert_eq!(cfg.jwt_expiry_secs, 3600);
        assert_eq!(cfg.cache_max_capacity, 10_000);
        assert_eq!(cfg.cache_idle_timeout_secs, 300);
        assert_eq!(cfg.cache_sweep_interval_secs, 60);
    }
}
  • Step 4: Add to main.rs and run test

Add mod config; to src/main.rs. Then:

cargo test config

Expected: test config::tests::test_config_defaults ... ok

  • Step 5: Write .env.example
DATABASE_URL=postgres://mercury:mercury@db:5432/mercury
JWT_SECRET=change_me_in_production
JWT_EXPIRY_SECS=3600
CACHE_MAX_CAPACITY=10000
CACHE_IDLE_TIMEOUT_SECS=300
CACHE_SWEEP_INTERVAL_SECS=60
  • Step 6: Commit
git add src/config.rs src/main.rs .env.example
git commit -m "feat: add Config struct loaded from env vars"

Task 3: Models

Files:

  • Create: src/models/mod.rs

  • Create: src/models/user.rs

  • Create: src/models/permission.rs

  • Create: src/models/query.rs

  • Create: src/models/blacklist.rs

  • Step 1: Write src/models/user.rs

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
    pub id: i32,
    pub username: String,
    #[serde(skip_serializing)]
    pub password_hash: String,
    pub permissions_mask: String,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreateUser {
    pub username: String,
    pub password: String,
    pub permissions_mask: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct UpdateUser {
    pub username: Option<String>,
    pub password: Option<String>,
    pub permissions_mask: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}
  • Step 2: Write src/models/permission.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Permission {
    pub id: i32,
    pub name: String,
    pub bit_value: String,
    pub description: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct CreatePermission {
    pub name: String,
    pub description: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct UpdatePermission {
    pub name: Option<String>,
    pub description: Option<String>,
}
  • Step 3: Write src/models/query.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct StoredQuery {
    pub id: Uuid,
    pub identifier: String,
    pub sql_template: String,
    pub description: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreateQuery {
    pub identifier: String,
    pub sql_template: String,
    pub description: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct UpdateQuery {
    pub sql_template: Option<String>,
    pub description: Option<String>,
}
  • Step 4: Write src/models/blacklist.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct BlacklistEntry {
    pub id: i32,
    pub pattern: String,
    pub method: Option<String>,
    pub reason: Option<String>,
    pub active: bool,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreateBlacklistEntry {
    pub pattern: String,
    pub method: Option<String>,
    pub reason: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct UpdateBlacklistEntry {
    pub pattern: Option<String>,
    pub method: Option<String>,
    pub reason: Option<String>,
    pub active: Option<bool>,
}
  • Step 5: Write src/models/mod.rs
pub mod blacklist;
pub mod permission;
pub mod query;
pub mod user;
  • Step 6: Add mod to main.rs and compile

Add mod models; to src/main.rs.

cargo build

Expected: Finished with no errors.

  • Step 7: Commit
git add src/models/
git commit -m "feat: add domain models for user, permission, query, blacklist"

Task 4: Database pool + migration

Files:

  • Create: src/db/mod.rs

  • Create: src/db/pool.rs

  • Create: src/db/migrations/001_initial.sql

  • Step 1: Write src/db/migrations/001_initial.sql

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(255) UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    permissions_mask TEXT NOT NULL DEFAULT '0',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE permissions (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) UNIQUE NOT NULL,
    bit_value TEXT UNIQUE NOT NULL,
    description TEXT
);

CREATE TABLE queries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    identifier VARCHAR(255) UNIQUE NOT NULL,
    sql_template TEXT NOT NULL,
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE blacklist (
    id SERIAL PRIMARY KEY,
    pattern VARCHAR(500) NOT NULL,
    method VARCHAR(10),
    reason TEXT,
    active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Seed permissions
INSERT INTO permissions (name, bit_value, description) VALUES
    ('READ',        '1',  'Can read via CRUD endpoints'),
    ('WRITE',       '2',  'Can insert/update via CRUD'),
    ('DELETE',      '4',  'Can delete via CRUD'),
    ('ADMIN_QUERY', '8',  'Can manage query registry'),
    ('ADMIN_CACHE', '16', 'Can manage cache'),
    ('SUPER_ADMIN', '32', 'Full access');

-- Seed blacklist (users and permissions tables are admin-only)
INSERT INTO blacklist (pattern, method, reason, active) VALUES
    ('/api/users/**',       NULL, 'admin-only table', true),
    ('/api/permissions/**', NULL, 'admin-only table', true);
  • Step 2: Write src/db/pool.rs
use anyhow::Result;
use sqlx::{postgres::PgPoolOptions, PgPool};

pub async fn create_pool(database_url: &str) -> Result<PgPool> {
    let pool = PgPoolOptions::new()
        .max_connections(10)
        .connect(database_url)
        .await?;
    sqlx::migrate!("src/db/migrations").run(&pool).await?;
    Ok(pool)
}
  • Step 3: Write src/db/mod.rs
pub mod pool;
pub use pool::create_pool;
  • Step 4: Add mod to main.rs and compile

Add mod db; to src/main.rs.

cargo build

Expected: Finished with no errors.

  • Step 5: Commit
git add src/db/ 
git commit -m "feat: add database pool and initial migration"

Task 5: AppState

Files:

  • Create: src/state.rs

  • Step 1: Write src/state.rs

use std::sync::{
    atomic::{AtomicU64, Ordering},
    Arc,
};
use std::time::{SystemTime, UNIX_EPOCH};

use dashmap::DashMap;
use glob::Pattern;
use sqlx::PgPool;
use tokio::sync::RwLock;

use crate::config::Config;
use crate::models::blacklist::BlacklistEntry;

pub fn unix_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
}

#[derive(Clone)]
pub struct AppState {
    pub pool: PgPool,
    pub query_cache: QueryCache,
    pub blacklist_cache: BlacklistCache,
    pub config: Arc<Config>,
}

#[derive(Clone)]
pub struct QueryCache {
    pub map: Arc<DashMap<String, CacheEntry>>,
    pub hits: Arc<AtomicU64>,
    pub misses: Arc<AtomicU64>,
}

#[derive(Clone, Debug)]
pub struct CacheEntry {
    pub sql: String,
    pub param_order: Vec<String>,
    pub last_accessed_secs: Arc<AtomicU64>,
}

impl CacheEntry {
    pub fn new(sql: String, param_order: Vec<String>) -> Self {
        Self {
            sql,
            param_order,
            last_accessed_secs: Arc::new(AtomicU64::new(unix_now())),
        }
    }

    pub fn touch(&self) {
        self.last_accessed_secs.store(unix_now(), Ordering::Relaxed);
    }

    pub fn last_accessed(&self) -> u64 {
        self.last_accessed_secs.load(Ordering::Relaxed)
    }
}

impl QueryCache {
    pub fn new() -> Self {
        Self {
            map: Arc::new(DashMap::new()),
            hits: Arc::new(AtomicU64::new(0)),
            misses: Arc::new(AtomicU64::new(0)),
        }
    }

    pub fn get(&self, key: &str) -> Option<CacheEntry> {
        if let Some(entry) = self.map.get(key) {
            entry.touch();
            self.hits.fetch_add(1, Ordering::Relaxed);
            Some(entry.clone())
        } else {
            self.misses.fetch_add(1, Ordering::Relaxed);
            None
        }
    }

    pub fn insert(&self, key: String, entry: CacheEntry, max_capacity: usize) {
        if self.map.len() >= max_capacity {
            let oldest_key = self
                .map
                .iter()
                .min_by_key(|e| e.last_accessed())
                .map(|e| e.key().clone());
            if let Some(k) = oldest_key {
                self.map.remove(&k);
            }
        }
        self.map.insert(key, entry);
    }

    pub fn remove(&self, key: &str) {
        self.map.remove(key);
    }

    pub fn hits(&self) -> u64 {
        self.hits.load(Ordering::Relaxed)
    }

    pub fn misses(&self) -> u64 {
        self.misses.load(Ordering::Relaxed)
    }

    pub fn len(&self) -> usize {
        self.map.len()
    }

    pub fn flush(&self) {
        self.map.clear();
    }
}

#[derive(Clone)]
pub struct BlacklistCache {
    pub inner: Arc<RwLock<Vec<CompiledEntry>>>,
}

#[derive(Clone)]
pub struct CompiledEntry {
    pub entry: BlacklistEntry,
    pub pattern: Pattern,
}

impl BlacklistCache {
    pub fn new() -> Self {
        Self {
            inner: Arc::new(RwLock::new(Vec::new())),
        }
    }

    pub async fn load(&self, entries: Vec<BlacklistEntry>) {
        let compiled: Vec<CompiledEntry> = entries
            .into_iter()
            .filter_map(|e| {
                Pattern::new(&e.pattern)
                    .ok()
                    .map(|pattern| CompiledEntry { entry: e, pattern })
            })
            .collect();
        let mut guard = self.inner.write().await;
        *guard = compiled;
    }

    pub async fn is_blocked(&self, method: &str, path: &str) -> bool {
        let guard = self.inner.read().await;
        guard.iter().any(|compiled| {
            if !compiled.entry.active {
                return false;
            }
            let method_matches = compiled
                .entry
                .method
                .as_deref()
                .map(|m| m.eq_ignore_ascii_case(method))
                .unwrap_or(true);
            method_matches && compiled.pattern.matches(path)
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_cache_insert_and_get() {
        let cache = QueryCache::new();
        let entry = CacheEntry::new("SELECT 1".into(), vec![]);
        cache.insert("key1".into(), entry, 100);
        let got = cache.get("key1");
        assert!(got.is_some());
        assert_eq!(got.unwrap().sql, "SELECT 1");
        assert_eq!(cache.hits(), 1);
        assert_eq!(cache.misses(), 0);
    }

    #[test]
    fn test_cache_miss() {
        let cache = QueryCache::new();
        let got = cache.get("missing");
        assert!(got.is_none());
        assert_eq!(cache.misses(), 1);
    }

    #[test]
    fn test_cache_capacity_evicts_oldest() {
        let cache = QueryCache::new();
        let e1 = CacheEntry::new("SELECT 1".into(), vec![]);
        // force e1 to be older
        e1.last_accessed_secs.store(1, Ordering::Relaxed);
        cache.map.insert("old".into(), e1);
        let e2 = CacheEntry::new("SELECT 2".into(), vec![]);
        cache.insert("new".into(), e2, 1); // capacity=1, should evict "old"
        assert!(cache.map.get("old").is_none());
        assert!(cache.map.get("new").is_some());
    }

    #[tokio::test]
    async fn test_blacklist_blocks_pattern() {
        use chrono::Utc;
        let cache = BlacklistCache::new();
        let entry = BlacklistEntry {
            id: 1,
            pattern: "/api/users/**".into(),
            method: None,
            reason: None,
            active: true,
            created_at: Utc::now(),
        };
        cache.load(vec![entry]).await;
        assert!(cache.is_blocked("GET", "/api/users/42").await);
        assert!(!cache.is_blocked("GET", "/api/orders/1").await);
    }

    #[tokio::test]
    async fn test_blacklist_method_specific() {
        use chrono::Utc;
        let cache = BlacklistCache::new();
        let entry = BlacklistEntry {
            id: 2,
            pattern: "/api/secrets".into(),
            method: Some("GET".into()),
            reason: None,
            active: true,
            created_at: Utc::now(),
        };
        cache.load(vec![entry]).await;
        assert!(cache.is_blocked("GET", "/api/secrets").await);
        assert!(!cache.is_blocked("POST", "/api/secrets").await);
    }
}
  • Step 2: Add mod to main.rs

Add mod state; to src/main.rs.

  • Step 3: Run tests
cargo test state

Expected: 4 tests pass.

  • Step 4: Commit
git add src/state.rs src/main.rs
git commit -m "feat: add AppState with QueryCache and BlacklistCache"

Task 6: Cache sweep background task

Files:

  • Create: src/cache/mod.rs

  • Create: src/cache/sweep.rs

  • Step 1: Write src/cache/sweep.rs

use std::time::Duration;
use crate::state::QueryCache;

pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) {
    tokio::spawn(async move {
        let interval = Duration::from_secs(sweep_interval_secs);
        loop {
            tokio::time::sleep(interval).await;
            let now = crate::state::unix_now();
            cache.map.retain(|_, entry| {
                now.saturating_sub(entry.last_accessed()) < idle_timeout_secs
            });
        }
    });
}
  • Step 2: Write src/cache/mod.rs
pub mod sweep;
pub use sweep::spawn_sweep_task;
  • Step 3: Add mod to main.rs and compile

Add mod cache; to src/main.rs.

cargo build

Expected: Finished with no errors.

  • Step 4: Commit
git add src/cache/
git commit -m "feat: add cache sweep background task for TTI eviction"

Task 7: JWT auth module

Files:

  • Create: src/auth/mod.rs

  • Step 1: Write failing tests

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_encode_decode_roundtrip() {
        let secret = "test_secret";
        let token = encode_jwt("alice", 63u128, secret, 3600).unwrap();
        let claims = decode_jwt(&token, secret).unwrap();
        assert_eq!(claims.sub, "alice");
        assert_eq!(claims.permissions_mask(), 63u128);
    }

    #[test]
    fn test_expired_token_rejected() {
        let secret = "test_secret";
        let token = encode_jwt("alice", 1u128, secret, 0).unwrap();
        // exp=0 means already expired
        let result = decode_jwt(&token, secret);
        assert!(result.is_err());
    }
}
  • Step 2: Run test — expect compile failure
cargo test auth
  • Step 3: Implement src/auth/mod.rs
pub mod middleware;

use anyhow::Result;
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};

pub mod permissions {
    pub const READ: u128 = 1;
    pub const WRITE: u128 = 2;
    pub const DELETE: u128 = 4;
    pub const ADMIN_QUERY: u128 = 8;
    pub const ADMIN_CACHE: u128 = 16;
    pub const SUPER_ADMIN: u128 = 32;
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,
    pub permissions: String, // u128 stored as decimal string
    pub exp: usize,
}

impl Claims {
    pub fn permissions_mask(&self) -> u128 {
        self.permissions.parse().unwrap_or(0)
    }

    pub fn has_permission(&self, bit: u128) -> bool {
        self.permissions_mask() & bit != 0
    }
}

pub fn encode_jwt(username: &str, mask: u128, secret: &str, expiry_secs: u64) -> Result<String> {
    let exp = (Utc::now().timestamp() as u64 + expiry_secs) as usize;
    let claims = Claims {
        sub: username.to_string(),
        permissions: mask.to_string(),
        exp,
    };
    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )?;
    Ok(token)
}

pub fn decode_jwt(token: &str, secret: &str) -> Result<Claims> {
    let data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::default(),
    )?;
    Ok(data.claims)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_encode_decode_roundtrip() {
        let secret = "test_secret";
        let token = encode_jwt("alice", 63u128, secret, 3600).unwrap();
        let claims = decode_jwt(&token, secret).unwrap();
        assert_eq!(claims.sub, "alice");
        assert_eq!(claims.permissions_mask(), 63u128);
    }

    #[test]
    fn test_has_permission() {
        let claims = Claims {
            sub: "bob".into(),
            permissions: "9".into(), // READ (1) + ADMIN_QUERY (8)
            exp: 9999999999,
        };
        assert!(claims.has_permission(permissions::READ));
        assert!(claims.has_permission(permissions::ADMIN_QUERY));
        assert!(!claims.has_permission(permissions::SUPER_ADMIN));
    }
}
  • Step 4: Create stub for middleware (needed for mod declaration)

Create src/auth/middleware.rs as an empty stub:

// filled in Task 8
  • Step 5: Add mod to main.rs and run tests

Add mod auth; to src/main.rs.

cargo test auth::tests

Expected: 2 tests pass (test_encode_decode_roundtrip, test_has_permission).

  • Step 6: Commit
git add src/auth/
git commit -m "feat: add JWT encode/decode with u128 bitmask permissions"

Task 8: Auth middleware + blacklist middleware

Files:

  • Modify: src/auth/middleware.rs

  • Step 1: Write src/auth/middleware.rs

use axum::{
    extract::{Request, State},
    http::StatusCode,
    middleware::Next,
    response::Response,
};

use crate::{auth::decode_jwt, state::AppState};

pub async fn blacklist_layer(
    State(state): State<AppState>,
    req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let method = req.method().as_str().to_uppercase();
    let path = req.uri().path().to_string();
    if state.blacklist_cache.is_blocked(&method, &path).await {
        return Err(StatusCode::FORBIDDEN);
    }
    Ok(next.run(req).await)
}

pub fn extract_bearer(req: &Request) -> Option<String> {
    req.headers()
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .map(|s| s.to_string())
}

pub async fn require_auth(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
    let claims = decode_jwt(&token, &state.config.jwt_secret)
        .map_err(|_| StatusCode::UNAUTHORIZED)?;
    req.extensions_mut().insert(claims);
    Ok(next.run(req).await)
}

pub async fn require_super_admin(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
    let claims = decode_jwt(&token, &state.config.jwt_secret)
        .map_err(|_| StatusCode::UNAUTHORIZED)?;
    if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) {
        return Err(StatusCode::FORBIDDEN);
    }
    req.extensions_mut().insert(claims);
    Ok(next.run(req).await)
}

pub async fn require_admin_query(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
    let claims = decode_jwt(&token, &state.config.jwt_secret)
        .map_err(|_| StatusCode::UNAUTHORIZED)?;
    if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) {
        return Err(StatusCode::FORBIDDEN);
    }
    req.extensions_mut().insert(claims);
    Ok(next.run(req).await)
}

pub async fn require_admin_cache(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
    let claims = decode_jwt(&token, &state.config.jwt_secret)
        .map_err(|_| StatusCode::UNAUTHORIZED)?;
    if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) {
        return Err(StatusCode::FORBIDDEN);
    }
    req.extensions_mut().insert(claims);
    Ok(next.run(req).await)
}
  • Step 2: Compile
cargo build

Expected: Finished with no errors.

  • Step 3: Commit
git add src/auth/middleware.rs
git commit -m "feat: add blacklist and JWT permission middlewares"

Task 9: Auth login route

Files:

  • Create: src/routes/mod.rs

  • Create: src/routes/auth.rs

  • Step 1: Write src/routes/auth.rs

use axum::{extract::State, http::StatusCode, Json};
use serde_json::{json, Value};

use crate::{
    auth::encode_jwt,
    models::user::LoginRequest,
    state::AppState,
};

pub async fn login(
    State(state): State<AppState>,
    Json(body): Json<LoginRequest>,
) -> Result<Json<Value>, StatusCode> {
    let user = sqlx::query_as!(
        crate::models::user::User,
        "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE username = $1",
        body.username
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::UNAUTHORIZED)?;

    let valid = bcrypt::verify(&body.password, &user.password_hash)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if !valid {
        return Err(StatusCode::UNAUTHORIZED);
    }

    let mask: u128 = user.permissions_mask.parse().unwrap_or(0);
    let token = encode_jwt(&user.username, mask, &state.config.jwt_secret, state.config.jwt_expiry_secs)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(json!({ "token": token })))
}
  • Step 2: Write src/routes/mod.rs
pub mod admin;
pub mod auth;
pub mod crud;
  • Step 3: Create stub files to satisfy mod declarations

Create src/routes/crud.rs:

// filled in Task 10

Create src/routes/admin/mod.rs:

// filled in Task 15
  • Step 4: Add mod to main.rs and compile

Add mod routes; to src/main.rs.

cargo build

Expected: Finished.

  • Step 5: Commit
git add src/routes/
git commit -m "feat: add /auth/login route with bcrypt verification"

Task 10: CRUD route + query builder

Files:

  • Modify: src/routes/crud.rs

  • Step 1: Write failing test

In src/routes/crud.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_select_all() {
        let (sql, params, key) = build_query("GET", "orders", None, &[], &[]).unwrap();
        assert_eq!(sql, "SELECT * FROM orders");
        assert!(params.is_empty());
        assert_eq!(key, "GET:orders:");
    }

    #[test]
    fn test_build_select_by_id() {
        let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap();
        assert_eq!(sql, "SELECT * FROM orders WHERE id = $1");
        assert_eq!(params, vec!["42".to_string()]);
        assert_eq!(key, "GET:orders:by_id");
    }

    #[test]
    fn test_build_insert() {
        let cols = vec![("email".into(), "a@b.com".into()), ("name".into(), "Alice".into())];
        let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap();
        assert_eq!(sql, "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *");
        assert_eq!(params, vec!["a@b.com", "Alice"]);
        assert_eq!(key, "POST:users:email,name");
    }

    #[test]
    fn test_build_update() {
        let cols = vec![("name".into(), "Bob".into())];
        let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap();
        assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *");
        assert_eq!(params, vec!["Bob", "7"]);
        assert_eq!(key, "PUT:users:name:by_id");
    }

    #[test]
    fn test_build_delete() {
        let (sql, params, key) = build_query("DELETE", "users", Some("3"), &[], &[]).unwrap();
        assert_eq!(sql, "DELETE FROM users WHERE id = $1");
        assert_eq!(params, vec!["3"]);
        assert_eq!(key, "DELETE:users:by_id");
    }

    #[test]
    fn test_build_select_with_filters() {
        let filters = vec![("status".into(), "active".into()), ("role".into(), "admin".into())];
        let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap();
        assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2");
        assert_eq!(params, vec!["admin", "active"]);
        assert_eq!(key, "GET:users:role,status");
    }

    #[test]
    fn test_rejects_invalid_table_name() {
        let result = build_query("GET", "users; DROP TABLE users--", None, &[], &[]);
        assert!(result.is_err());
    }
}
  • Step 2: Run test — expect compile failure
cargo test crud
  • Step 3: Implement src/routes/crud.rs
use anyhow::{anyhow, Result};
use axum::{
    extract::{Path, Query, State},
    http::{Method, StatusCode},
    Json,
};
use serde_json::Value;
use sqlx::postgres::PgRow;
use sqlx::Row;
use std::collections::HashMap;

use crate::state::{AppState, CacheEntry};

fn validate_identifier(name: &str) -> Result<()> {
    if name.chars().all(|c| c.is_alphanumeric() || c == '_') {
        Ok(())
    } else {
        Err(anyhow!("invalid identifier: {}", name))
    }
}

/// Returns (sql, ordered_param_values, cache_key)
/// body_cols: sorted (col, val) pairs from request body
/// filter_cols: sorted (col, val) pairs from query params
pub fn build_query(
    method: &str,
    table: &str,
    id: Option<&str>,
    body_cols: &[(String, String)],
    filter_cols: &[(String, String)],
) -> Result<(String, Vec<String>, String)> {
    validate_identifier(table)?;
    for (col, _) in body_cols.iter().chain(filter_cols.iter()) {
        validate_identifier(col)?;
    }

    let mut sorted_body = body_cols.to_vec();
    sorted_body.sort_by(|a, b| a.0.cmp(&b.0));
    let mut sorted_filters = filter_cols.to_vec();
    sorted_filters.sort_by(|a, b| a.0.cmp(&b.0));

    match method.to_uppercase().as_str() {
        "GET" => {
            if let Some(id_val) = id {
                let sql = format!("SELECT * FROM {} WHERE id = $1", table);
                let key = format!("GET:{}:by_id", table);
                Ok((sql, vec![id_val.to_string()], key))
            } else if sorted_filters.is_empty() {
                let sql = format!("SELECT * FROM {}", table);
                let key = format!("GET:{}:", table);
                Ok((sql, vec![], key))
            } else {
                let col_names: Vec<String> = sorted_filters.iter().map(|(c, _)| c.clone()).collect();
                let where_clause: Vec<String> = col_names
                    .iter()
                    .enumerate()
                    .map(|(i, c)| format!("{} = ${}", c, i + 1))
                    .collect();
                let sql = format!("SELECT * FROM {} WHERE {}", table, where_clause.join(" AND "));
                let params: Vec<String> = sorted_filters.iter().map(|(_, v)| v.clone()).collect();
                let key = format!("GET:{}:{}", table, col_names.join(","));
                Ok((sql, params, key))
            }
        }
        "POST" => {
            if sorted_body.is_empty() {
                return Err(anyhow!("POST requires a body with at least one field"));
            }
            let cols: Vec<String> = sorted_body.iter().map(|(c, _)| c.clone()).collect();
            let placeholders: Vec<String> = (1..=cols.len()).map(|i| format!("${}", i)).collect();
            let sql = format!(
                "INSERT INTO {} ({}) VALUES ({}) RETURNING *",
                table,
                cols.join(", "),
                placeholders.join(", ")
            );
            let params: Vec<String> = sorted_body.iter().map(|(_, v)| v.clone()).collect();
            let key = format!("POST:{}:{}", table, cols.join(","));
            Ok((sql, params, key))
        }
        "PUT" => {
            let id_val = id.ok_or_else(|| anyhow!("PUT requires an id"))?;
            if sorted_body.is_empty() {
                return Err(anyhow!("PUT requires a body with at least one field"));
            }
            let cols: Vec<String> = sorted_body.iter().map(|(c, _)| c.clone()).collect();
            let set_clause: Vec<String> = cols
                .iter()
                .enumerate()
                .map(|(i, c)| format!("{} = ${}", c, i + 1))
                .collect();
            let id_placeholder = cols.len() + 1;
            let sql = format!(
                "UPDATE {} SET {} WHERE id = ${} RETURNING *",
                table,
                set_clause.join(", "),
                id_placeholder
            );
            let mut params: Vec<String> = sorted_body.iter().map(|(_, v)| v.clone()).collect();
            params.push(id_val.to_string());
            let key = format!("PUT:{}:{}:by_id", table, cols.join(","));
            Ok((sql, params, key))
        }
        "DELETE" => {
            let id_val = id.ok_or_else(|| anyhow!("DELETE requires an id"))?;
            let sql = format!("DELETE FROM {} WHERE id = $1", table);
            let key = format!("DELETE:{}:by_id", table);
            Ok((sql, vec![id_val.to_string()], key))
        }
        m => Err(anyhow!("unsupported method: {}", m)),
    }
}

pub fn pg_row_to_json(row: PgRow) -> Value {
    let columns = row.columns();
    let mut map = serde_json::Map::new();
    for col in columns {
        let name = col.name().to_string();
        let type_name = col.type_info().name();
        let val = match type_name {
            "INT2" | "INT4" | "INT8" | "SERIAL" => row
                .try_get::<i64, _>(col.ordinal())
                .map(|v| Value::Number(v.into()))
                .unwrap_or(Value::Null),
            "FLOAT4" | "FLOAT8" => row
                .try_get::<f64, _>(col.ordinal())
                .ok()
                .and_then(|v| serde_json::Number::from_f64(v))
                .map(Value::Number)
                .unwrap_or(Value::Null),
            "BOOL" => row
                .try_get::<bool, _>(col.ordinal())
                .map(Value::Bool)
                .unwrap_or(Value::Null),
            "UUID" => row
                .try_get::<uuid::Uuid, _>(col.ordinal())
                .map(|v| Value::String(v.to_string()))
                .unwrap_or(Value::Null),
            "TIMESTAMPTZ" | "TIMESTAMP" => row
                .try_get::<chrono::DateTime<chrono::Utc>, _>(col.ordinal())
                .map(|v| Value::String(v.to_rfc3339()))
                .unwrap_or(Value::Null),
            _ => row
                .try_get::<String, _>(col.ordinal())
                .map(Value::String)
                .unwrap_or(Value::Null),
        };
        map.insert(name, val);
    }
    Value::Object(map)
}

pub async fn handle_crud(
    State(state): State<AppState>,
    method: Method,
    Path(params): Path<HashMap<String, String>>,
    Query(query_params): Query<HashMap<String, String>>,
    body: Option<Json<HashMap<String, Value>>>,
) -> Result<Json<Value>, StatusCode> {
    let table = params.get("table").ok_or(StatusCode::BAD_REQUEST)?;
    let id = params.get("id").map(|s| s.as_str());
    let method_str = method.as_str();

    let body_cols: Vec<(String, String)> = body
        .map(|Json(b)| {
            b.into_iter()
                .filter_map(|(k, v)| match v {
                    Value::String(s) => Some((k, s)),
                    Value::Number(n) => Some((k, n.to_string())),
                    Value::Bool(b) => Some((k, b.to_string())),
                    _ => None,
                })
                .collect()
        })
        .unwrap_or_default();

    let filter_cols: Vec<(String, String)> = query_params.into_iter().collect();

    let (sql, params_vals, cache_key) =
        build_query(method_str, table, id, &body_cols, &filter_cols)
            .map_err(|_| StatusCode::BAD_REQUEST)?;

    let cached = state.query_cache.get(&cache_key);
    let _entry = if cached.is_none() {
        let entry = CacheEntry::new(sql.clone(), vec![]);
        state
            .query_cache
            .insert(cache_key, entry.clone(), state.config.cache_max_capacity);
        entry
    } else {
        cached.unwrap()
    };

    let mut q = sqlx::query(&sql);
    for val in &params_vals {
        q = q.bind(val.as_str());
    }

    match method_str.to_uppercase().as_str() {
        "GET" => {
            let rows = q
                .fetch_all(&state.pool)
                .await
                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
            let json_rows: Vec<Value> = rows.into_iter().map(pg_row_to_json).collect();
            Ok(Json(Value::Array(json_rows)))
        }
        "POST" | "PUT" => {
            let row = q
                .fetch_one(&state.pool)
                .await
                .map_err(|e| {
                    if e.to_string().contains("no rows") {
                        StatusCode::NOT_FOUND
                    } else {
                        StatusCode::INTERNAL_SERVER_ERROR
                    }
                })?;
            Ok(Json(pg_row_to_json(row)))
        }
        "DELETE" => {
            q.execute(&state.pool)
                .await
                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
            Ok(Json(serde_json::json!({ "deleted": true })))
        }
        _ => Err(StatusCode::METHOD_NOT_ALLOWED),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_select_all() {
        let (sql, params, key) = build_query("GET", "orders", None, &[], &[]).unwrap();
        assert_eq!(sql, "SELECT * FROM orders");
        assert!(params.is_empty());
        assert_eq!(key, "GET:orders:");
    }

    #[test]
    fn test_build_select_by_id() {
        let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap();
        assert_eq!(sql, "SELECT * FROM orders WHERE id = $1");
        assert_eq!(params, vec!["42".to_string()]);
        assert_eq!(key, "GET:orders:by_id");
    }

    #[test]
    fn test_build_insert() {
        let cols = vec![("email".into(), "a@b.com".into()), ("name".into(), "Alice".into())];
        let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap();
        assert_eq!(sql, "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *");
        assert_eq!(params, vec!["a@b.com", "Alice"]);
        assert_eq!(key, "POST:users:email,name");
    }

    #[test]
    fn test_build_update() {
        let cols = vec![("name".into(), "Bob".into())];
        let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap();
        assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *");
        assert_eq!(params, vec!["Bob", "7"]);
        assert_eq!(key, "PUT:users:name:by_id");
    }

    #[test]
    fn test_build_delete() {
        let (sql, params, key) = build_query("DELETE", "users", Some("3"), &[], &[]).unwrap();
        assert_eq!(sql, "DELETE FROM users WHERE id = $1");
        assert_eq!(params, vec!["3"]);
        assert_eq!(key, "DELETE:users:by_id");
    }

    #[test]
    fn test_build_select_with_filters() {
        let filters = vec![("status".into(), "active".into()), ("role".into(), "admin".into())];
        let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap();
        assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2");
        assert_eq!(params, vec!["admin", "active"]);
        assert_eq!(key, "GET:users:role,status");
    }

    #[test]
    fn test_rejects_invalid_table_name() {
        let result = build_query("GET", "users; DROP TABLE users--", None, &[], &[]);
        assert!(result.is_err());
    }
}
  • Step 4: Run tests
cargo test crud::tests

Expected: 7 tests pass.

  • Step 5: Commit
git add src/routes/crud.rs
git commit -m "feat: add dynamic CRUD query builder with cache integration"

Task 11: Admin routes — queries

Files:

  • Create: src/routes/admin/queries.rs

  • Step 1: Write src/routes/admin/queries.rs

use axum::{
    extract::{Extension, Path, Query, State},
    http::StatusCode,
    Json,
};
use serde_json::{json, Value};
use std::collections::HashMap;

use crate::{
    auth::Claims,
    models::query::{CreateQuery, StoredQuery, UpdateQuery},
    state::AppState,
};

pub async fn list_queries(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
) -> Result<Json<Vec<StoredQuery>>, StatusCode> {
    let queries = sqlx::query_as!(
        StoredQuery,
        "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries ORDER BY created_at DESC"
    )
    .fetch_all(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(queries))
}

pub async fn get_query(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(identifier): Path<String>,
) -> Result<Json<StoredQuery>, StatusCode> {
    let q = sqlx::query_as!(
        StoredQuery,
        "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1",
        identifier
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(q))
}

pub async fn create_query(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Json(body): Json<CreateQuery>,
) -> Result<Json<StoredQuery>, StatusCode> {
    let q = sqlx::query_as!(
        StoredQuery,
        "INSERT INTO queries (identifier, sql_template, description) VALUES ($1, $2, $3) RETURNING id, identifier, sql_template, description, created_at, updated_at",
        body.identifier,
        body.sql_template,
        body.description
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(q))
}

pub async fn update_query(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(identifier): Path<String>,
    Json(body): Json<UpdateQuery>,
) -> Result<Json<StoredQuery>, StatusCode> {
    let existing = sqlx::query_as!(
        StoredQuery,
        "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1",
        identifier
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    let new_sql = body.sql_template.as_deref().unwrap_or(&existing.sql_template);
    let new_desc = body.description.as_deref().or(existing.description.as_deref());

    let q = sqlx::query_as!(
        StoredQuery,
        "UPDATE queries SET sql_template = $1, description = $2, updated_at = now() WHERE identifier = $3 RETURNING id, identifier, sql_template, description, created_at, updated_at",
        new_sql,
        new_desc,
        identifier
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    state.query_cache.remove(&identifier);
    Ok(Json(q))
}

pub async fn delete_query(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(identifier): Path<String>,
) -> Result<Json<Value>, StatusCode> {
    sqlx::query!("DELETE FROM queries WHERE identifier = $1", identifier)
        .execute(&state.pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    state.query_cache.remove(&identifier);
    Ok(Json(json!({ "deleted": true })))
}

pub async fn execute_query(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(identifier): Path<String>,
    Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, StatusCode> {
    let stored = sqlx::query_as!(
        StoredQuery,
        "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1",
        identifier
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    let mut sql = stored.sql_template.clone();
    let mut bound_vals: Vec<String> = Vec::new();
    let mut idx = 1usize;
    let mut sorted_params: Vec<(String, String)> = params.into_iter().collect();
    sorted_params.sort_by(|a, b| a.0.cmp(&b.0));
    for (name, val) in &sorted_params {
        let placeholder = format!(":{}", name);
        if sql.contains(&placeholder) {
            sql = sql.replace(&placeholder, &format!("${}", idx));
            bound_vals.push(val.clone());
            idx += 1;
        }
    }

    let mut q = sqlx::query(&sql);
    for val in &bound_vals {
        q = q.bind(val.as_str());
    }

    let rows = q
        .fetch_all(&state.pool)
        .await
        .map_err(|e| {
            tracing::error!("query execution error: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let json_rows: Vec<Value> = rows
        .into_iter()
        .map(crate::routes::crud::pg_row_to_json)
        .collect();

    Ok(Json(Value::Array(json_rows)))
}
  • Step 2: Compile
cargo build

Expected: Finished.

  • Step 3: Commit
git add src/routes/admin/queries.rs
git commit -m "feat: add admin query registry CRUD routes"

Task 12: Admin routes — cache, users, permissions, blacklist

Files:

  • Create: src/routes/admin/cache.rs

  • Create: src/routes/admin/users.rs

  • Create: src/routes/admin/permissions.rs

  • Create: src/routes/admin/blacklist.rs

  • Modify: src/routes/admin/mod.rs

  • Step 1: Write src/routes/admin/cache.rs

use axum::{extract::State, http::StatusCode, Json};
use serde_json::{json, Value};

use crate::{auth::Claims, state::AppState};
use axum::extract::Extension;

pub async fn cache_stats(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
) -> Json<Value> {
    Json(json!({
        "size": state.query_cache.len(),
        "hits": state.query_cache.hits(),
        "misses": state.query_cache.misses(),
    }))
}

pub async fn flush_cache(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
) -> Result<Json<Value>, StatusCode> {
    state.query_cache.flush();
    Ok(Json(json!({ "flushed": true })))
}
  • Step 2: Write src/routes/admin/users.rs
use axum::{
    extract::{Extension, Path, State},
    http::StatusCode,
    Json,
};
use serde_json::{json, Value};

use crate::{
    auth::{encode_jwt, Claims},
    models::user::{CreateUser, UpdateUser, User},
    state::AppState,
};

pub async fn list_users(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
) -> Result<Json<Vec<User>>, StatusCode> {
    let users = sqlx::query_as!(
        User,
        "SELECT id, username, password_hash, permissions_mask, created_at FROM users ORDER BY id"
    )
    .fetch_all(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(users))
}

pub async fn get_user(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(id): Path<i32>,
) -> Result<Json<User>, StatusCode> {
    let user = sqlx::query_as!(
        User,
        "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE id = $1",
        id
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(user))
}

pub async fn create_user(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Json(body): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
    let hash = bcrypt::hash(&body.password, bcrypt::DEFAULT_COST)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let mask = body.permissions_mask.unwrap_or_else(|| "0".into());
    let user = sqlx::query_as!(
        User,
        "INSERT INTO users (username, password_hash, permissions_mask) VALUES ($1, $2, $3) RETURNING id, username, password_hash, permissions_mask, created_at",
        body.username,
        hash,
        mask
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(user))
}

pub async fn update_user(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(id): Path<i32>,
    Json(body): Json<UpdateUser>,
) -> Result<Json<User>, StatusCode> {
    let existing = sqlx::query_as!(
        User,
        "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE id = $1",
        id
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    let new_username = body.username.as_deref().unwrap_or(&existing.username);
    let new_hash = if let Some(pw) = &body.password {
        bcrypt::hash(pw, bcrypt::DEFAULT_COST).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    } else {
        existing.password_hash.clone()
    };
    let new_mask = body.permissions_mask.as_deref().unwrap_or(&existing.permissions_mask);

    let user = sqlx::query_as!(
        User,
        "UPDATE users SET username = $1, password_hash = $2, permissions_mask = $3 WHERE id = $4 RETURNING id, username, password_hash, permissions_mask, created_at",
        new_username,
        new_hash,
        new_mask,
        id
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(user))
}

pub async fn delete_user(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(id): Path<i32>,
) -> Result<Json<Value>, StatusCode> {
    sqlx::query!("DELETE FROM users WHERE id = $1", id)
        .execute(&state.pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(json!({ "deleted": true })))
}

pub async fn grant_permission(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path((id, bit_value)): Path<(i32, String)>,
) -> Result<Json<User>, StatusCode> {
    let user = sqlx::query_as!(
        User,
        "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE id = $1",
        id
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    let current: u128 = user.permissions_mask.parse().unwrap_or(0);
    let bit: u128 = bit_value.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
    let new_mask = (current | bit).to_string();

    let updated = sqlx::query_as!(
        User,
        "UPDATE users SET permissions_mask = $1 WHERE id = $2 RETURNING id, username, password_hash, permissions_mask, created_at",
        new_mask,
        id
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(updated))
}

pub async fn revoke_permission(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path((id, bit_value)): Path<(i32, String)>,
) -> Result<Json<User>, StatusCode> {
    let user = sqlx::query_as!(
        User,
        "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE id = $1",
        id
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    let current: u128 = user.permissions_mask.parse().unwrap_or(0);
    let bit: u128 = bit_value.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
    let new_mask = (current & !bit).to_string();

    let updated = sqlx::query_as!(
        User,
        "UPDATE users SET permissions_mask = $1 WHERE id = $2 RETURNING id, username, password_hash, permissions_mask, created_at",
        new_mask,
        id
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(updated))
}
  • Step 3: Write src/routes/admin/permissions.rs
use axum::{
    extract::{Extension, Path, State},
    http::StatusCode,
    Json,
};
use serde_json::{json, Value};

use crate::{
    auth::Claims,
    models::permission::{CreatePermission, Permission, UpdatePermission},
    state::AppState,
};

pub async fn list_permissions(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
) -> Result<Json<Vec<Permission>>, StatusCode> {
    let perms = sqlx::query_as!(
        Permission,
        "SELECT id, name, bit_value, description FROM permissions ORDER BY id"
    )
    .fetch_all(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(perms))
}

pub async fn create_permission(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Json(body): Json<CreatePermission>,
) -> Result<Json<Permission>, StatusCode> {
    let existing: Vec<String> = sqlx::query_scalar!("SELECT bit_value FROM permissions")
        .fetch_all(&state.pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .into_iter()
        .flatten()
        .collect();

    let next_bit = (0u128..)
        .map(|i| 1u128 << i)
        .find(|bit| !existing.contains(&bit.to_string()))
        .ok_or(StatusCode::INSUFFICIENT_STORAGE)?;

    let perm = sqlx::query_as!(
        Permission,
        "INSERT INTO permissions (name, bit_value, description) VALUES ($1, $2, $3) RETURNING id, name, bit_value, description",
        body.name,
        next_bit.to_string(),
        body.description
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(perm))
}

pub async fn update_permission(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(id): Path<i32>,
    Json(body): Json<UpdatePermission>,
) -> Result<Json<Permission>, StatusCode> {
    let existing = sqlx::query_as!(
        Permission,
        "SELECT id, name, bit_value, description FROM permissions WHERE id = $1",
        id
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    let new_name = body.name.as_deref().unwrap_or(&existing.name);
    let new_desc = body.description.as_deref().or(existing.description.as_deref());

    let perm = sqlx::query_as!(
        Permission,
        "UPDATE permissions SET name = $1, description = $2 WHERE id = $3 RETURNING id, name, bit_value, description",
        new_name,
        new_desc,
        id
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(perm))
}

pub async fn delete_permission(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(id): Path<i32>,
) -> Result<Json<Value>, StatusCode> {
    sqlx::query!("DELETE FROM permissions WHERE id = $1", id)
        .execute(&state.pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(json!({ "deleted": true })))
}
  • Step 4: Write src/routes/admin/blacklist.rs
use axum::{
    extract::{Extension, Path, State},
    http::StatusCode,
    Json,
};
use serde_json::{json, Value};

use crate::{
    auth::Claims,
    models::blacklist::{BlacklistEntry, CreateBlacklistEntry, UpdateBlacklistEntry},
    state::AppState,
};

async fn reload_blacklist(state: &AppState) -> Result<(), StatusCode> {
    let entries = sqlx::query_as!(
        BlacklistEntry,
        "SELECT id, pattern, method, reason, active, created_at FROM blacklist ORDER BY id"
    )
    .fetch_all(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    state.blacklist_cache.load(entries).await;
    Ok(())
}

pub async fn list_blacklist(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
) -> Result<Json<Vec<BlacklistEntry>>, StatusCode> {
    let entries = sqlx::query_as!(
        BlacklistEntry,
        "SELECT id, pattern, method, reason, active, created_at FROM blacklist ORDER BY id"
    )
    .fetch_all(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(entries))
}

pub async fn create_blacklist_entry(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Json(body): Json<CreateBlacklistEntry>,
) -> Result<Json<BlacklistEntry>, StatusCode> {
    let entry = sqlx::query_as!(
        BlacklistEntry,
        "INSERT INTO blacklist (pattern, method, reason) VALUES ($1, $2, $3) RETURNING id, pattern, method, reason, active, created_at",
        body.pattern,
        body.method,
        body.reason
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    reload_blacklist(&state).await?;
    Ok(Json(entry))
}

pub async fn update_blacklist_entry(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(id): Path<i32>,
    Json(body): Json<UpdateBlacklistEntry>,
) -> Result<Json<BlacklistEntry>, StatusCode> {
    let existing = sqlx::query_as!(
        BlacklistEntry,
        "SELECT id, pattern, method, reason, active, created_at FROM blacklist WHERE id = $1",
        id
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    let new_pattern = body.pattern.as_deref().unwrap_or(&existing.pattern);
    let new_method = body.method.as_deref().or(existing.method.as_deref());
    let new_reason = body.reason.as_deref().or(existing.reason.as_deref());
    let new_active = body.active.unwrap_or(existing.active);

    let entry = sqlx::query_as!(
        BlacklistEntry,
        "UPDATE blacklist SET pattern = $1, method = $2, reason = $3, active = $4 WHERE id = $5 RETURNING id, pattern, method, reason, active, created_at",
        new_pattern,
        new_method,
        new_reason,
        new_active,
        id
    )
    .fetch_one(&state.pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    reload_blacklist(&state).await?;
    Ok(Json(entry))
}

pub async fn delete_blacklist_entry(
    State(state): State<AppState>,
    Extension(_claims): Extension<Claims>,
    Path(id): Path<i32>,
) -> Result<Json<Value>, StatusCode> {
    sqlx::query!("DELETE FROM blacklist WHERE id = $1", id)
        .execute(&state.pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    reload_blacklist(&state).await?;
    Ok(Json(json!({ "deleted": true })))
}
  • Step 5: Write src/routes/admin/mod.rs
pub mod blacklist;
pub mod cache;
pub mod permissions;
pub mod queries;
pub mod users;

use axum::{
    middleware,
    routing::{delete, get, post, put},
    Router,
};

use crate::{
    auth::middleware::{require_admin_cache, require_admin_query, require_super_admin},
    state::AppState,
};

pub fn admin_router(state: AppState) -> Router<AppState> {
    let query_routes = Router::new()
        .route("/", get(queries::list_queries).post(queries::create_query))
        .route(
            "/:identifier",
            get(queries::get_query)
                .put(queries::update_query)
                .delete(queries::delete_query),
        )
        .route("/:identifier/execute", get(queries::execute_query))
        .route_layer(middleware::from_fn_with_state(
            state.clone(),
            require_admin_query,
        ));

    let cache_routes = Router::new()
        .route("/stats", get(cache::cache_stats))
        .route("/", delete(cache::flush_cache))
        .route_layer(middleware::from_fn_with_state(
            state.clone(),
            require_admin_cache,
        ));

    let super_routes = Router::new()
        .route("/users", get(users::list_users).post(users::create_user))
        .route(
            "/users/:id",
            get(users::get_user)
                .put(users::update_user)
                .delete(users::delete_user),
        )
        .route(
            "/users/:id/permissions/grant/:bit_value",
            post(users::grant_permission),
        )
        .route(
            "/users/:id/permissions/revoke/:bit_value",
            delete(users::revoke_permission),
        )
        .route(
            "/permissions",
            get(permissions::list_permissions).post(permissions::create_permission),
        )
        .route(
            "/permissions/:id",
            put(permissions::update_permission).delete(permissions::delete_permission),
        )
        .route(
            "/blacklist",
            get(blacklist::list_blacklist).post(blacklist::create_blacklist_entry),
        )
        .route(
            "/blacklist/:id",
            put(blacklist::update_blacklist_entry).delete(blacklist::delete_blacklist_entry),
        )
        .route_layer(middleware::from_fn_with_state(
            state.clone(),
            require_super_admin,
        ));

    Router::new()
        .nest("/queries", query_routes)
        .nest("/cache", cache_routes)
        .merge(super_routes)
}
  • Step 6: Compile
cargo build

Expected: Finished.

  • Step 7: Commit
git add src/routes/admin/
git commit -m "feat: add admin routes for cache, users, permissions, and blacklist"

Task 13: main.rs — full wiring

Files:

  • Modify: src/main.rs

  • Step 1: Write full src/main.rs

mod auth;
mod cache;
mod config;
mod db;
mod models;
mod routes;
mod state;

use std::sync::Arc;

use axum::{middleware, routing::{delete, get, post, put}, Router};
use tower_http::{cors::CorsLayer, services::ServeDir};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

use crate::{
    auth::middleware::blacklist_layer,
    cache::spawn_sweep_task,
    config::Config,
    db::create_pool,
    models::blacklist::BlacklistEntry,
    routes::{
        admin::admin_router,
        auth::login,
        crud::handle_crud,
    },
    state::{AppState, BlacklistCache, QueryCache},
};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenvy::dotenv().ok();

    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| "mercury=info".into()))
        .with(tracing_subscriber::fmt::layer())
        .init();

    let config = Arc::new(Config::from_env()?);
    let pool = create_pool(&config.database_url).await?;

    // Seed admin user if not present
    let count: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM users WHERE username = 'admin'")
        .fetch_one(&pool)
        .await?
        .unwrap_or(0);
    if count == 0 {
        let hash = bcrypt::hash("admin", bcrypt::DEFAULT_COST)?;
        sqlx::query!(
            "INSERT INTO users (username, password_hash, permissions_mask) VALUES ('admin', $1, '63')",
            hash
        )
        .execute(&pool)
        .await?;
        tracing::info!("seeded admin user");
    }

    let query_cache = QueryCache::new();
    let blacklist_cache = BlacklistCache::new();

    // Load blacklist from DB into memory
    let entries = sqlx::query_as!(
        BlacklistEntry,
        "SELECT id, pattern, method, reason, active, created_at FROM blacklist ORDER BY id"
    )
    .fetch_all(&pool)
    .await?;
    blacklist_cache.load(entries).await;

    // Start cache sweep
    spawn_sweep_task(
        query_cache.clone(),
        config.cache_idle_timeout_secs,
        config.cache_sweep_interval_secs,
    );

    let state = AppState {
        pool,
        query_cache,
        blacklist_cache,
        config: config.clone(),
    };

    let crud_routes = Router::new()
        .route("/api/:table", get(handle_crud).post(handle_crud))
        .route("/api/:table/:id", get(handle_crud).put(handle_crud).delete(handle_crud))
        .route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer));

    let app = Router::new()
        .route("/auth/login", post(login))
        .merge(crud_routes)
        .nest("/admin", admin_router(state.clone()))
        .nest_service("/", ServeDir::new("ui/dist"))
        .layer(CorsLayer::permissive())
        .with_state(state);

    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));
    tracing::info!("listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}
  • Step 2: Compile
cargo build

Expected: Finished.

  • Step 3: Commit
git add src/main.rs
git commit -m "feat: wire full Axum router with middleware, CRUD, admin, and static file serving"

Task 14: Docker setup

Files:

  • Create: Dockerfile

  • Create: docker-compose.yml

  • Step 1: Write Dockerfile

# Stage 1: Build Vue UI
FROM node:20-alpine AS ui-builder
WORKDIR /ui
COPY ui/.npmrc .
COPY ui/package*.json .
RUN npm install
COPY ui/ .
RUN npm run build

# Stage 2: Build Rust API
FROM rust:1.75-slim AS api-builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
RUN cargo build --release

# Stage 3: Final image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=api-builder /app/target/release/mercury .
COPY --from=ui-builder /ui/dist ./ui/dist
EXPOSE 3000
CMD ["./mercury"]
  • Step 2: Write docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: mercury
      POSTGRES_PASSWORD: mercury
      POSTGRES_DB: mercury
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mercury"]
      interval: 5s
      timeout: 5s
      retries: 5

  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://mercury:mercury@db:5432/mercury
      JWT_SECRET: ${JWT_SECRET:-change_me_in_production}
      JWT_EXPIRY_SECS: ${JWT_EXPIRY_SECS:-3600}
      CACHE_MAX_CAPACITY: ${CACHE_MAX_CAPACITY:-10000}
      CACHE_IDLE_TIMEOUT_SECS: ${CACHE_IDLE_TIMEOUT_SECS:-300}
      CACHE_SWEEP_INTERVAL_SECS: ${CACHE_SWEEP_INTERVAL_SECS:-60}
    depends_on:
      db:
        condition: service_healthy
    mem_limit: 512m
    mem_reservation: 256m

volumes:
  postgres_data:
  • Step 3: Commit
git add Dockerfile docker-compose.yml
git commit -m "feat: add multi-stage Dockerfile and docker-compose"

Task 15: Vue UI scaffold

Files:

  • Create: ui/.npmrc

  • Create: ui/package.json

  • Create: ui/vite.config.ts

  • Create: ui/src/main.ts

  • Create: ui/src/App.vue

  • Create: ui/src/router/index.ts

  • Create: ui/src/stores/auth.ts

  • Step 1: Write ui/.npmrc

@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/
  • Step 2: Write ui/package.json
{
  "name": "mercury-ui",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@nychthemeron/library": "latest",
    "pinia": "^2.1.0",
    "vue": "^3.4.0",
    "vue-router": "^4.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "typescript": "^5.3.0",
    "vite": "^5.0.0",
    "vue-tsc": "^2.0.0"
  }
}
  • Step 3: Write ui/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': 'http://localhost:3000',
      '/auth': 'http://localhost:3000',
      '/admin': 'http://localhost:3000',
    },
  },
  build: {
    outDir: 'dist',
  },
})
  • Step 4: Write ui/src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface Claims {
  sub: string
  permissions: string
  exp: number
}

function parseJwt(token: string): Claims | null {
  try {
    const payload = token.split('.')[1]
    return JSON.parse(atob(payload)) as Claims
  } catch {
    return null
  }
}

export const useAuthStore = defineStore('auth', () => {
  const token = ref<string | null>(localStorage.getItem('mercury_token'))
  const claims = computed<Claims | null>(() =>
    token.value ? parseJwt(token.value) : null
  )
  const isAuthenticated = computed(() => {
    if (!claims.value) return false
    return claims.value.exp * 1000 > Date.now()
  })
  const username = computed(() => claims.value?.sub ?? '')

  function hasPermission(bit: bigint): boolean {
    if (!claims.value) return false
    const mask = BigInt(claims.value.permissions)
    return (mask & bit) !== 0n
  }

  const isSuperAdmin = computed(() => hasPermission(32n))

  async function login(username: string, password: string): Promise<void> {
    const res = await fetch('/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password }),
    })
    if (!res.ok) throw new Error('Invalid credentials')
    const data = await res.json()
    token.value = data.token
    localStorage.setItem('mercury_token', data.token)
  }

  function logout() {
    token.value = null
    localStorage.removeItem('mercury_token')
  }

  function authHeaders(): Record<string, string> {
    return token.value ? { Authorization: `Bearer ${token.value}` } : {}
  }

  return { token, claims, isAuthenticated, username, isSuperAdmin, hasPermission, login, logout, authHeaders }
})
  • Step 5: Write ui/src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', component: () => import('../views/Login.vue') },
    {
      path: '/admin',
      component: () => import('../views/admin/Layout.vue'),
      children: [
        { path: 'queries', component: () => import('../views/admin/Queries.vue') },
        { path: 'users', component: () => import('../views/admin/Users.vue') },
        { path: 'permissions', component: () => import('../views/admin/Permissions.vue') },
        { path: 'blacklist', component: () => import('../views/admin/Blacklist.vue') },
        { path: 'cache', component: () => import('../views/admin/Cache.vue') },
      ],
      meta: { requiresAuth: true },
    },
    { path: '/', redirect: '/admin/queries' },
  ],
})

router.beforeEach((to) => {
  const auth = useAuthStore()
  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return '/login'
  }
  if (to.path === '/login' && auth.isAuthenticated) {
    return '/admin/queries'
  }
})

export default router
  • Step 6: Write ui/src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import NychthemeronLibrary from '@nychthemeron/library'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(NychthemeronLibrary, { theme: 'dark' })
app.mount('#app')
  • Step 7: Write ui/src/App.vue
<template>
  <RouterView />
</template>
  • Step 8: Create ui/src/views/Login.vue
<template>
  <div class="login-page">
    <div class="login-card">
      <h1>Mercury</h1>
      <form @submit.prevent="handleLogin">
        <NInput v-model="username" placeholder="Username" />
        <NInput v-model="password" type="password" placeholder="Password" />
        <NButton type="submit" :loading="loading">Sign In</NButton>
        <p v-if="error" class="error">{{ error }}</p>
      </form>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'

const auth = useAuthStore()
const router = useRouter()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')

async function handleLogin() {
  loading.value = true
  error.value = ''
  try {
    await auth.login(username.value, password.value)
    router.push('/admin/queries')
  } catch {
    error.value = 'Invalid username or password'
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login-page {
  display: flex;
  height: 100vh;
  align-items: center;
  justify-content: center;
}
.login-card {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 2rem;
  min-width: 320px;
}
form {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.error {
  color: var(--color-error, #f44);
}
</style>
  • Step 9: Commit
git add ui/
git commit -m "feat: scaffold Vue 3 UI with auth store, router, and login view"

Task 16: Admin views

Files:

  • Create: ui/src/views/admin/Layout.vue

  • Create: ui/src/views/admin/Queries.vue

  • Create: ui/src/views/admin/Users.vue

  • Create: ui/src/views/admin/Permissions.vue

  • Create: ui/src/views/admin/Blacklist.vue

  • Create: ui/src/views/admin/Cache.vue

  • Step 1: Write ui/src/views/admin/Layout.vue

<template>
  <div class="admin-layout">
    <nav class="sidebar">
      <h2>Mercury</h2>
      <ul>
        <li><RouterLink to="/admin/queries">Queries</RouterLink></li>
        <li><RouterLink to="/admin/users">Users</RouterLink></li>
        <li><RouterLink to="/admin/permissions">Permissions</RouterLink></li>
        <li><RouterLink to="/admin/blacklist">Blacklist</RouterLink></li>
        <li><RouterLink to="/admin/cache">Cache</RouterLink></li>
      </ul>
      <NButton @click="handleLogout" variant="ghost">Logout</NButton>
    </nav>
    <main class="content">
      <RouterView />
    </main>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'

const auth = useAuthStore()
const router = useRouter()
function handleLogout() {
  auth.logout()
  router.push('/login')
}
</script>

<style scoped>
.admin-layout { display: flex; height: 100vh; }
.sidebar { width: 220px; padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; border-right: 1px solid var(--color-border, #333); }
.sidebar ul { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.content { flex: 1; padding: 2rem; overflow-y: auto; }
</style>
  • Step 2: Write ui/src/views/admin/Queries.vue
<template>
  <div>
    <h2>Query Registry</h2>
    <NButton @click="showCreate = true">+ New Query</NButton>

    <NTable :data="queries" :columns="columns" />

    <NModal v-model="showCreate" title="New Query">
      <form @submit.prevent="submitCreate">
        <NInput v-model="form.identifier" placeholder="identifier-slug" />
        <NInput v-model="form.description" placeholder="Description (optional)" />
        <NTextarea v-model="form.sql_template" placeholder="SELECT * FROM ... WHERE id = :id" rows="6" />
        <NButton type="submit">Save</NButton>
      </form>
    </NModal>

    <NModal v-model="showEdit" title="Edit Query">
      <form @submit.prevent="submitEdit">
        <NTextarea v-model="editForm.sql_template" rows="6" />
        <NInput v-model="editForm.description" placeholder="Description" />
        <NButton type="submit">Update</NButton>
      </form>
    </NModal>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'

const auth = useAuthStore()
const queries = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editIdentifier = ref('')
const form = ref({ identifier: '', sql_template: '', description: '' })
const editForm = ref({ sql_template: '', description: '' })

const columns = [
  { key: 'identifier', label: 'Identifier' },
  { key: 'description', label: 'Description' },
  { key: 'updated_at', label: 'Updated' },
  {
    key: 'actions', label: 'Actions',
    render: (row: any) => ({
      edit: () => openEdit(row),
      delete: () => deleteQuery(row.identifier),
    }),
  },
]

async function load() {
  const res = await fetch('/admin/queries', { headers: auth.authHeaders() })
  queries.value = await res.json()
}

async function submitCreate() {
  await fetch('/admin/queries', {
    method: 'POST',
    headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
    body: JSON.stringify(form.value),
  })
  showCreate.value = false
  form.value = { identifier: '', sql_template: '', description: '' }
  load()
}

function openEdit(row: any) {
  editIdentifier.value = row.identifier
  editForm.value = { sql_template: row.sql_template, description: row.description ?? '' }
  showEdit.value = true
}

async function submitEdit() {
  await fetch(`/admin/queries/${editIdentifier.value}`, {
    method: 'PUT',
    headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
    body: JSON.stringify(editForm.value),
  })
  showEdit.value = false
  load()
}

async function deleteQuery(identifier: string) {
  if (!confirm(`Delete query "${identifier}"?`)) return
  await fetch(`/admin/queries/${identifier}`, { method: 'DELETE', headers: auth.authHeaders() })
  load()
}

onMounted(load)
</script>
  • Step 3: Write ui/src/views/admin/Users.vue
<template>
  <div>
    <h2>Users</h2>
    <NButton @click="showCreate = true">+ New User</NButton>
    <NTable :data="users" :columns="columns" />

    <NModal v-model="showCreate" title="New User">
      <form @submit.prevent="submitCreate">
        <NInput v-model="form.username" placeholder="Username" />
        <NInput v-model="form.password" type="password" placeholder="Password" />
        <NInput v-model="form.permissions_mask" placeholder="Permissions mask (default: 0)" />
        <NButton type="submit">Create</NButton>
      </form>
    </NModal>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'

const auth = useAuthStore()
const users = ref<any[]>([])
const showCreate = ref(false)
const form = ref({ username: '', password: '', permissions_mask: '0' })

const columns = [
  { key: 'id', label: 'ID' },
  { key: 'username', label: 'Username' },
  { key: 'permissions_mask', label: 'Permissions Mask' },
  { key: 'created_at', label: 'Created' },
  {
    key: 'actions', label: 'Actions',
    render: (row: any) => ({ delete: () => deleteUser(row.id) }),
  },
]

async function load() {
  const res = await fetch('/admin/users', { headers: auth.authHeaders() })
  users.value = await res.json()
}

async function submitCreate() {
  await fetch('/admin/users', {
    method: 'POST',
    headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
    body: JSON.stringify(form.value),
  })
  showCreate.value = false
  form.value = { username: '', password: '', permissions_mask: '0' }
  load()
}

async function deleteUser(id: number) {
  if (!confirm('Delete this user?')) return
  await fetch(`/admin/users/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
  load()
}

onMounted(load)
</script>
  • Step 4: Write ui/src/views/admin/Permissions.vue
<template>
  <div>
    <h2>Permissions</h2>
    <NButton @click="showCreate = true">+ New Permission</NButton>
    <NTable :data="permissions" :columns="columns" />

    <NModal v-model="showCreate" title="New Permission">
      <form @submit.prevent="submitCreate">
        <NInput v-model="form.name" placeholder="PERMISSION_NAME" />
        <NInput v-model="form.description" placeholder="Description" />
        <NButton type="submit">Create</NButton>
      </form>
    </NModal>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'

const auth = useAuthStore()
const permissions = ref<any[]>([])
const showCreate = ref(false)
const form = ref({ name: '', description: '' })

const columns = [
  { key: 'id', label: 'ID' },
  { key: 'name', label: 'Name' },
  { key: 'bit_value', label: 'Bit Value' },
  { key: 'description', label: 'Description' },
  {
    key: 'actions', label: 'Actions',
    render: (row: any) => ({ delete: () => deletePermission(row.id) }),
  },
]

async function load() {
  const res = await fetch('/admin/permissions', { headers: auth.authHeaders() })
  permissions.value = await res.json()
}

async function submitCreate() {
  await fetch('/admin/permissions', {
    method: 'POST',
    headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
    body: JSON.stringify(form.value),
  })
  showCreate.value = false
  form.value = { name: '', description: '' }
  load()
}

async function deletePermission(id: number) {
  if (!confirm('Delete this permission?')) return
  await fetch(`/admin/permissions/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
  load()
}

onMounted(load)
</script>
  • Step 5: Write ui/src/views/admin/Blacklist.vue
<template>
  <div>
    <h2>Route Blacklist</h2>
    <NButton @click="showCreate = true">+ Add Pattern</NButton>
    <NTable :data="entries" :columns="columns" />

    <NModal v-model="showCreate" title="New Blacklist Entry">
      <form @submit.prevent="submitCreate">
        <NInput v-model="form.pattern" placeholder="/api/sensitive/**" />
        <NInput v-model="form.method" placeholder="HTTP method (blank = all)" />
        <NInput v-model="form.reason" placeholder="Reason" />
        <NButton type="submit">Add</NButton>
      </form>
    </NModal>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'

const auth = useAuthStore()
const entries = ref<any[]>([])
const showCreate = ref(false)
const form = ref({ pattern: '', method: '', reason: '' })

const columns = [
  { key: 'id', label: 'ID' },
  { key: 'pattern', label: 'Pattern' },
  { key: 'method', label: 'Method' },
  { key: 'active', label: 'Active' },
  { key: 'reason', label: 'Reason' },
  {
    key: 'actions', label: 'Actions',
    render: (row: any) => ({
      toggle: () => toggleEntry(row),
      delete: () => deleteEntry(row.id),
    }),
  },
]

async function load() {
  const res = await fetch('/admin/blacklist', { headers: auth.authHeaders() })
  entries.value = await res.json()
}

async function submitCreate() {
  await fetch('/admin/blacklist', {
    method: 'POST',
    headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
    body: JSON.stringify({
      pattern: form.value.pattern,
      method: form.value.method || null,
      reason: form.value.reason || null,
    }),
  })
  showCreate.value = false
  form.value = { pattern: '', method: '', reason: '' }
  load()
}

async function toggleEntry(row: any) {
  await fetch(`/admin/blacklist/${row.id}`, {
    method: 'PUT',
    headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
    body: JSON.stringify({ active: !row.active }),
  })
  load()
}

async function deleteEntry(id: number) {
  if (!confirm('Delete this blacklist entry?')) return
  await fetch(`/admin/blacklist/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
  load()
}

onMounted(load)
</script>
  • Step 6: Write ui/src/views/admin/Cache.vue
<template>
  <div>
    <h2>Cache</h2>
    <div v-if="stats" class="stats">
      <NCard>
        <p>Size: {{ stats.size }}</p>
        <p>Hits: {{ stats.hits }}</p>
        <p>Misses: {{ stats.misses }}</p>
        <p>Hit rate: {{ hitRate }}%</p>
      </NCard>
    </div>
    <NButton @click="load">Refresh</NButton>
    <NButton variant="danger" @click="flushCache">Flush Cache</NButton>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'

const auth = useAuthStore()
const stats = ref<{ size: number; hits: number; misses: number } | null>(null)

const hitRate = computed(() => {
  if (!stats.value) return 0
  const total = stats.value.hits + stats.value.misses
  return total === 0 ? 0 : Math.round((stats.value.hits / total) * 100)
})

async function load() {
  const res = await fetch('/admin/cache/stats', { headers: auth.authHeaders() })
  stats.value = await res.json()
}

async function flushCache() {
  if (!confirm('Flush the entire query cache?')) return
  await fetch('/admin/cache', { method: 'DELETE', headers: auth.authHeaders() })
  load()
}

onMounted(load)
</script>

<style scoped>
.stats { margin-bottom: 1rem; }
</style>
  • Step 7: Commit
git add ui/src/views/
git commit -m "feat: add admin views for queries, users, permissions, blacklist, cache"

Task 17: README update

Files:

  • Modify: README.md

  • Step 1: Write README.md

# Mercury

A high-performance, monolithic Rust API with a dynamic CRUD engine, query registry cache, JWT bitmask permissions, and a Vue 3 admin frontend.

## Quick Start

```bash
cp .env.example .env          # set JWT_SECRET
docker compose up --build

API: http://localhost:3000/api
Admin UI: http://localhost:3000
Default credentials: admin / admin


API Contract

Authentication

POST /auth/login
Body: { "username": "...", "password": "..." }
Returns: { "token": "<JWT>" }

All admin routes require Authorization: Bearer <token>.


CRUD — Dynamic Table Access

Requests are mapped to the named PostgreSQL table. The SQL is generated, cached, and executed automatically.

GET    /api/{table}              List all rows (supports ?col=val filters)
GET    /api/{table}/{id}         Get row by id
POST   /api/{table}              Insert row (JSON body)
PUT    /api/{table}/{id}         Update row by id (JSON body)
DELETE /api/{table}/{id}         Delete row by id

Notes:

  • users and permissions tables are blacklisted from public CRUD — use the admin suite.
  • Filters are ANDed together: GET /api/orders?status=open&priority=high

Admin — Query Registry

Requires JWT with ADMIN_QUERY permission (bit 8).

GET    /admin/queries                          List all registered queries
POST   /admin/queries                          Register a raw SQL template
GET    /admin/queries/{identifier}             Get query by slug
PUT    /admin/queries/{identifier}             Update SQL template or description
DELETE /admin/queries/{identifier}             Remove query (evicts from cache)
GET    /admin/queries/{identifier}/execute     Execute query with ?param=val bindings

SQL templates use :param_name placeholders:

SELECT * FROM orders WHERE user_id = :user_id AND status = :status

Admin — Cache

Requires JWT with ADMIN_CACHE permission (bit 16).

GET    /admin/cache/stats    Cache size, hit count, miss count
DELETE /admin/cache          Flush entire cache

Admin — Users

Requires JWT with SUPER_ADMIN permission (bit 32).

GET    /admin/users                                   List users
POST   /admin/users                                   Create user
GET    /admin/users/{id}                              Get user
PUT    /admin/users/{id}                              Update user
DELETE /admin/users/{id}                              Delete user
POST   /admin/users/{id}/permissions/grant/{bit}      OR bit into permissions mask
DELETE /admin/users/{id}/permissions/revoke/{bit}     AND NOT bit from permissions mask

Admin — Permissions

Requires JWT with SUPER_ADMIN permission (bit 32).

GET    /admin/permissions          List permission definitions
POST   /admin/permissions          Create custom permission (auto-assigns next bit)
PUT    /admin/permissions/{id}     Update name/description
DELETE /admin/permissions/{id}     Remove permission

Admin — Route Blacklist

Requires JWT with SUPER_ADMIN permission (bit 32). Changes take effect immediately in memory.

GET    /admin/blacklist          List all entries
POST   /admin/blacklist          Add glob pattern
PUT    /admin/blacklist/{id}     Update entry (set active: false to disable)
DELETE /admin/blacklist/{id}     Remove entry

Pattern syntax: * matches one path segment, ** matches many.
Example: /api/sensitive/** blocks all methods under that path.


Permission Bitmask

Name Bit Value
READ 0 1
WRITE 1 2
DELETE 2 4
ADMIN_QUERY 3 8
ADMIN_CACHE 4 16
SUPER_ADMIN 5 32

Custom permissions are added via the admin suite and assigned the next available power-of-2 bit. Masks support up to 128 bits (u128).


Configuration

Variable Default Description
DATABASE_URL required PostgreSQL connection string
JWT_SECRET required HMAC-HS256 signing secret
JWT_EXPIRY_SECS 3600 Token lifetime in seconds
CACHE_MAX_CAPACITY 10000 Max query templates in memory
CACHE_IDLE_TIMEOUT_SECS 300 Evict after N seconds idle
CACHE_SWEEP_INTERVAL_SECS 60 Sweep interval for eviction task

Development

# API only (requires local Postgres)
cargo run

# UI dev server (proxies to local API)
cd ui && npm install && npm run dev

# Full stack
docker compose up --build

Stack

  • API: Rust, Axum, SQLx, PostgreSQL, DashMap, jsonwebtoken, bcrypt
  • Frontend: Vue 3, Vite, @nychthemeron/library (dark mode default)
  • Infra: Docker multi-stage build, docker compose

- [ ] **Step 2: Commit**

```bash
git add README.md
git commit -m "docs: update README with full API contract and configuration"

Self-Review Checklist

Spec Coverage

Spec requirement Task
Axum + SQLx + PostgreSQL Task 1, 4
Config from env vars Task 2
DashMap TTI cache with sweep Task 5, 6
Blacklist in-memory with glob Task 5
JWT with u128 bitmask Task 7
Auth + blacklist middleware Task 8
CRUD query builder Task 10
/auth/login Task 9
Admin query registry CRUD Task 11
Admin cache stats + flush Task 12
Admin user management Task 12
Admin permission definitions Task 12
Admin blacklist CRUD + reload Task 12
Router assembly + main.rs Task 13
Dockerfile multi-stage Task 14
docker-compose with mem_limit Task 14
Admin seeded in migration Task 4 (schema) + Task 13 (seed on startup)
Vue 3 + @nychthemeron/library Task 15
Dark mode default Task 15 (main.ts)
Login view Task 15
Admin views (5 pages) Task 16
README with API contract Task 17
users/permissions blacklisted Task 4 (migration seed)
Blacklist reloads on write Task 12 (blacklist.rs)
Permission grant/revoke endpoints Task 12 (users.rs)
Custom permissions auto-assign bit Task 12 (permissions.rs)

All spec requirements covered. No gaps found.