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 ¶ms_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:
usersandpermissionstables 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.