3438 lines
93 KiB
Markdown
3438 lines
93 KiB
Markdown
# 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**
|
|
|
|
```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**
|
|
|
|
```rust
|
|
#[tokio::main]
|
|
async fn main() {
|
|
println!("Mercury starting...");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify it compiles**
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Compiling mercury v0.1.0` then `Finished`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```rust
|
|
#[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**
|
|
|
|
```bash
|
|
cargo test config
|
|
```
|
|
Expected: `error[E0433]: failed to resolve: use of undeclared crate or module`
|
|
|
|
- [ ] **Step 3: Implement Config**
|
|
|
|
```rust
|
|
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:
|
|
|
|
```bash
|
|
cargo test config
|
|
```
|
|
Expected: `test config::tests::test_config_defaults ... ok`
|
|
|
|
- [ ] **Step 5: Write .env.example**
|
|
|
|
```env
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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`.
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Finished` with no errors.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
pub mod pool;
|
|
pub use pool::create_pool;
|
|
```
|
|
|
|
- [ ] **Step 4: Add mod to main.rs and compile**
|
|
|
|
Add `mod db;` to `src/main.rs`.
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Finished` with no errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```bash
|
|
cargo test state
|
|
```
|
|
Expected: 4 tests pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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`.
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Finished` with no errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
#[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**
|
|
|
|
```bash
|
|
cargo test auth
|
|
```
|
|
|
|
- [ ] **Step 3: Implement src/auth/mod.rs**
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
// filled in Task 8
|
|
```
|
|
|
|
- [ ] **Step 5: Add mod to main.rs and run tests**
|
|
|
|
Add `mod auth;` to `src/main.rs`.
|
|
|
|
```bash
|
|
cargo test auth::tests
|
|
```
|
|
Expected: 2 tests pass (`test_encode_decode_roundtrip`, `test_has_permission`).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Finished` with no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
pub mod admin;
|
|
pub mod auth;
|
|
pub mod crud;
|
|
```
|
|
|
|
- [ ] **Step 3: Create stub files to satisfy mod declarations**
|
|
|
|
Create `src/routes/crud.rs`:
|
|
```rust
|
|
// filled in Task 10
|
|
```
|
|
|
|
Create `src/routes/admin/mod.rs`:
|
|
```rust
|
|
// filled in Task 15
|
|
```
|
|
|
|
- [ ] **Step 4: Add mod to main.rs and compile**
|
|
|
|
Add `mod routes;` to `src/main.rs`.
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Finished`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```rust
|
|
#[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**
|
|
|
|
```bash
|
|
cargo test crud
|
|
```
|
|
|
|
- [ ] **Step 3: Implement src/routes/crud.rs**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```bash
|
|
cargo test crud::tests
|
|
```
|
|
Expected: 7 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Finished`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Finished`.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```rust
|
|
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**
|
|
|
|
```bash
|
|
cargo build
|
|
```
|
|
Expected: `Finished`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```yaml
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```vue
|
|
<template>
|
|
<RouterView />
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 8: Create ui/src/views/Login.vue**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```markdown
|
|
# 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:
|
|
```sql
|
|
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
|
|
|
|
```bash
|
|
# 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.
|