47 KiB
Dynamic CORS + CDN Proxy Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add runtime-editable CORS origins backed by PostgreSQL (managed via CRUD API, protected by super_admin bypass mask) and a CDN proxy that maps keys to local container URLs with a DB registry; add loading spinners to all admin views.
Architecture: CORS uses a custom async Axum middleware (cors_layer) that reads from an Arc<RwLock<CorsState>> in-memory cache, populated from the cors_origins table and reloaded after every mutation via the CRUD hook. The CDN adds dedicated routes at /api/cdn and /api/cdn/:key that read from cdn_objects and proxy to internal URLs via reqwest; write operations are blocked for non-super-admins by blacklist migration. Both features get Vue admin views.
Tech Stack: Rust/axum 0.7, sqlx 0.7, tokio, reqwest 0.12, Vue 3/TypeScript
Global Constraints
- All DB access uses
sqlx::query_as/sqlx::query(notsqlx::query!macro — no compile-time DB required). - Blacklist super-admin bypass bit is
'32'(string, matches existing migrations). - All new Rust modules follow existing
pub moddeclaration in their parentmod.rs. - Vue components import auth store via
import { useAuthStore } from '../../stores/auth'. - No new npm dependencies — existing NychButton, NychDialog, NychInputText, NychSelect are available globally.
cargo testmust pass after every Rust task.
File Map
| File | Action |
|---|---|
docker-compose.yml |
Modify — add cdn (MinIO), cdn-init, cdn_data volume; api depends on cdn |
Dockerfile |
Modify — add minio-download stage; copy minio + mc binaries to final image |
entrypoint.sh |
Modify — start MinIO in background, wait for health, create bucket before Postgres |
.env.example |
Modify — add CDN_ACCESS_KEY, CDN_SECRET_KEY, CDN_BUCKET |
src/db/migrations/007_cors_origins.sql |
Create |
src/db/migrations/008_cdn_objects.sql |
Create |
src/state.rs |
Modify — add CorsCache, CorsState, http_client; update AppState |
src/models/cdn.rs |
Create |
src/models/mod.rs |
Modify — add pub mod cdn |
src/auth/middleware.rs |
Modify — add cors_layer |
src/routes/cdn.rs |
Create |
src/routes/mod.rs |
Modify — add pub mod cdn |
src/routes/crud.rs |
Modify — add reload_cors, hook |
src/routes/admin/tables.rs |
Modify — add cors_origins, cdn_objects to PROTECTED_TABLES |
src/main.rs |
Modify — remove build_cors, wire cors_cache, http_client, cors_layer, CDN routes |
Cargo.toml |
Modify — add reqwest, remove cors feature from tower-http |
ui/src/assets/main.css |
Modify — add spinner keyframe + classes |
ui/src/views/admin/Blacklist.vue |
Modify — add loading state |
ui/src/views/admin/ApiKeys.vue |
Modify — add loading state |
ui/src/views/admin/Cache.vue |
Modify — add loading state |
ui/src/views/admin/Queries.vue |
Modify — add loading state |
ui/src/views/admin/Tables.vue |
Modify — add loading state |
ui/src/views/admin/Users.vue |
Modify — add loading state |
ui/src/views/admin/Permissions.vue |
Modify — add loading state |
ui/src/views/admin/Cors.vue |
Create |
ui/src/views/admin/Cdn.vue |
Create |
ui/src/router/index.ts |
Modify — add /admin/cors and /admin/cdn routes |
ui/src/views/admin/Layout.vue |
Modify — add CORS and CDN nav items |
Task 0: Infrastructure — Docker Compose + Dockerfile + Entrypoint
Already done — these files were updated during planning. Mark complete and move to Task 1.
Files (already modified):
docker-compose.yml—cdn(MinIO) +cdn-initservices;cdn_datavolume;apidepends_oncdn healthyDockerfile—minio-downloadstage (alpine:3, downloadsminio+mcviaTARGETARCH); copies binaries to final image; exposes 9000/9001entrypoint.sh— starts MinIO in background, loopsmc alias setuntil ready, creates$CDN_BUCKETwith anonymous download policy, then starts Postgres + Mercury.env.example— documentsCDN_ACCESS_KEY,CDN_SECRET_KEY,CDN_BUCKET, and the URL prefix pattern
CDN URL pattern (important for admin users registering objects):
- docker-compose stack:
http://cdn:9000/mercury/<filename> - standalone image:
http://localhost:9000/mercury/<filename>
The bucket is created with anonymous read (mc anonymous set download), so the Mercury proxy makes unauthenticated GET requests — no credentials needed in the API.
- Mark complete — files already written.
Task 1: DB Migrations
Files:
- Create:
src/db/migrations/007_cors_origins.sql - Create:
src/db/migrations/008_cdn_objects.sql
Interfaces:
-
Produces:
cors_origins(id, origin, created_at)andcdn_objects(id, key, url, content_type, description, created_at)tables; blacklist seeds for both. -
Step 1: Write 007_cors_origins.sql
CREATE TABLE cors_origins (
id SERIAL PRIMARY KEY,
origin TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Restrict CRUD mutations to super-admins (bit 32). Mirrors blacklist/queries pattern.
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
('/api/cors_origins', NULL, 'admin-only table', true, '32'),
('/api/cors_origins/**', NULL, 'admin-only table', true, '32');
- Step 2: Write 008_cdn_objects.sql
CREATE TABLE cdn_objects (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
content_type TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Block mutations to /api/cdn for non-super-admins. GET is unblocked (no entry).
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
('/api/cdn', 'POST,PUT,PATCH,DELETE', 'CDN write operations are super-admin only', true, '32'),
('/api/cdn/**', 'POST,PUT,PATCH,DELETE', 'CDN write operations are super-admin only', true, '32');
- Step 3: Verify migrations compile
cd /home/mcpeakml/code/rust/Mercury && cargo build 2>&1 | tail -3
Expected: Finished dev (sqlx migrate! scans the directory; new files are included automatically).
Task 2: CorsCache + AppState
Files:
- Modify:
src/state.rs - Modify:
Cargo.toml
Interfaces:
-
Produces:
CorsCache::new() -> CorsCacheCorsCache::load(origins: Vec<String>) -> ()(async)AppState.cors_cache: CorsCacheAppState.http_client: reqwest::Client
-
Step 1: Add reqwest to Cargo.toml
Replace the tower-http line and add reqwest:
tower-http = { version = "0.5", features = ["fs"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
- Step 2: Write the failing tests
Add to the #[cfg(test)] block at the bottom of src/state.rs:
#[tokio::test]
async fn test_cors_cache_wildcard() {
let cache = CorsCache::new();
cache.load(vec!["*".to_string()]).await;
let guard = cache.inner.read().await;
assert!(guard.wildcard);
assert!(guard.origins.is_empty());
}
#[tokio::test]
async fn test_cors_cache_specific_origin() {
let cache = CorsCache::new();
cache.load(vec!["https://example.com".to_string()]).await;
let guard = cache.inner.read().await;
assert!(!guard.wildcard);
assert_eq!(guard.origins.len(), 1);
assert_eq!(guard.origins[0], "https://example.com");
}
#[tokio::test]
async fn test_cors_cache_empty() {
let cache = CorsCache::new();
cache.load(vec![]).await;
let guard = cache.inner.read().await;
assert!(!guard.wildcard);
assert!(guard.origins.is_empty());
}
#[tokio::test]
async fn test_cors_cache_load_replaces() {
let cache = CorsCache::new();
cache.load(vec!["https://a.com".to_string()]).await;
cache.load(vec!["https://b.com".to_string()]).await;
let guard = cache.inner.read().await;
assert_eq!(guard.origins.len(), 1);
assert_eq!(guard.origins[0], "https://b.com");
}
- Step 3: Run tests to verify they fail
cd /home/mcpeakml/code/rust/Mercury && cargo test cors_cache 2>&1 | tail -5
Expected: compile error — CorsCache not defined yet.
- Step 4: Add CorsCache + CorsState structs and update AppState
In src/state.rs, add these imports at the top:
use axum::http::HeaderValue;
Add after the BlacklistCache impl block:
#[derive(Clone)]
pub struct CorsCache {
pub inner: Arc<RwLock<CorsState>>,
}
#[derive(Clone, Default)]
pub struct CorsState {
pub wildcard: bool,
pub origins: Vec<HeaderValue>,
}
impl CorsCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(CorsState::default())),
}
}
pub async fn load(&self, origins: Vec<String>) {
let wildcard = origins.iter().any(|o| o == "*");
let parsed: Vec<HeaderValue> = origins
.iter()
.filter(|o| *o != "*")
.filter_map(|o| o.parse().ok())
.collect();
let mut guard = self.inner.write().await;
*guard = CorsState { wildcard, origins: parsed };
}
}
Update AppState:
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub query_cache: QueryCache,
pub blacklist_cache: BlacklistCache,
pub cors_cache: CorsCache,
pub http_client: reqwest::Client,
pub config: Arc<Config>,
}
- Step 5: Run tests to verify they pass
cd /home/mcpeakml/code/rust/Mercury && cargo test cors_cache 2>&1 | tail -5
Expected: test result: ok. 4 passed
Task 3: CdnObject Model
Files:
- Create:
src/models/cdn.rs - Modify:
src/models/mod.rs
Interfaces:
-
Produces:
CdnObject,CreateCdnObject,UpdateCdnObject— used byroutes/cdn.rs -
Step 1: Create src/models/cdn.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct CdnObject {
pub id: i32,
pub key: String,
pub url: String,
pub content_type: Option<String>,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCdnObject {
pub key: String,
pub url: String,
pub content_type: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateCdnObject {
pub key: Option<String>,
pub url: Option<String>,
pub content_type: Option<String>,
pub description: Option<String>,
}
- Step 2: Add to src/models/mod.rs
pub mod api_key;
pub mod blacklist;
pub mod cdn;
pub mod permission;
pub mod query;
pub mod user;
- Step 3: Verify compilation
cd /home/mcpeakml/code/rust/Mercury && cargo build 2>&1 | tail -3
Expected: Finished dev
Task 4: cors_layer Middleware
Files:
- Modify:
src/auth/middleware.rs
Interfaces:
-
Consumes:
AppState.cors_cache: CorsCache -
Produces:
cors_layer(State<AppState>, Request, Next) -> Response— applied globally inmain.rs -
Step 1: Update imports in src/auth/middleware.rs
Replace the existing imports block with:
use axum::{
extract::{Request, State},
http::{header, HeaderMap, HeaderValue, Method, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use crate::{
auth::{decode_jwt, resolve_api_key, Claims},
state::AppState,
};
- Step 2: Add cors_layer function
Add after the require_admin_cache function:
pub async fn cors_layer(
State(state): State<AppState>,
req: Request,
next: Next,
) -> Response {
let is_preflight = req.method() == Method::OPTIONS;
let origin_header = req.headers().get(header::ORIGIN).cloned();
enum CorsDecision {
None,
Wildcard,
Specific(HeaderValue),
}
let decision = {
let guard = state.cors_cache.inner.read().await;
if guard.wildcard {
CorsDecision::Wildcard
} else if let Some(origin) = origin_header.as_ref() {
if guard.origins.contains(origin) {
CorsDecision::Specific(origin.clone())
} else {
CorsDecision::None
}
} else {
CorsDecision::None
}
};
let (cors_origin, vary) = match &decision {
CorsDecision::None => (None, false),
CorsDecision::Wildcard => (Some(HeaderValue::from_static("*")), false),
CorsDecision::Specific(v) => (Some(v.clone()), true),
};
if is_preflight {
let mut headers = HeaderMap::new();
if let Some(origin) = cors_origin {
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
headers.insert(
header::ACCESS_CONTROL_ALLOW_METHODS,
HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS"),
);
headers.insert(
header::ACCESS_CONTROL_ALLOW_HEADERS,
HeaderValue::from_static("content-type, authorization"),
);
if vary {
headers.insert(header::VARY, HeaderValue::from_static("Origin"));
}
}
return (StatusCode::NO_CONTENT, headers).into_response();
}
let mut response = next.run(req).await;
if let Some(origin) = cors_origin {
response
.headers_mut()
.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
if vary {
response
.headers_mut()
.append(header::VARY, HeaderValue::from_static("Origin"));
}
}
response
}
- Step 3: Verify compilation
cd /home/mcpeakml/code/rust/Mercury && cargo build 2>&1 | tail -3
Expected: Finished dev
Task 5: CDN Route Handlers
Files:
- Create:
src/routes/cdn.rs - Modify:
src/routes/mod.rs
Interfaces:
-
Consumes:
AppState.pool,AppState.http_client,CdnObject,CreateCdnObject,UpdateCdnObject -
Produces:
cdn_list,cdn_proxy,cdn_create,cdn_update,cdn_delete— registered inmain.rs -
Step 1: Create src/routes/cdn.rs
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use serde_json::Value;
use sqlx::Row;
use crate::{
models::cdn::{CdnObject, CreateCdnObject, UpdateCdnObject},
state::AppState,
};
pub async fn cdn_list(
State(state): State<AppState>,
) -> Result<Json<Vec<CdnObject>>, StatusCode> {
let objects = sqlx::query_as::<_, CdnObject>(
"SELECT id, key, url, content_type, description, created_at \
FROM cdn_objects ORDER BY id",
)
.fetch_all(&state.pool)
.await
.map_err(|e| {
tracing::error!("cdn_list: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(objects))
}
pub async fn cdn_proxy(
State(state): State<AppState>,
Path(key): Path<String>,
) -> Result<impl IntoResponse, StatusCode> {
let row = sqlx::query(
"SELECT url, content_type FROM cdn_objects WHERE key = $1",
)
.bind(&key)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let url: String = row.try_get("url").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let content_type: Option<String> = row.try_get::<Option<String>, _>("content_type").ok().flatten();
let upstream = state
.http_client
.get(&url)
.send()
.await
.map_err(|e| {
tracing::error!("cdn_proxy upstream error for key={}: {}", key, e);
StatusCode::BAD_GATEWAY
})?;
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let bytes = upstream
.bytes()
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
let ct = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
Ok((status, [(header::CONTENT_TYPE, ct)], bytes))
}
pub async fn cdn_create(
State(state): State<AppState>,
Json(body): Json<CreateCdnObject>,
) -> Result<Json<CdnObject>, StatusCode> {
let obj = sqlx::query_as::<_, CdnObject>(
"INSERT INTO cdn_objects (key, url, content_type, description) \
VALUES ($1, $2, $3, $4) \
RETURNING id, key, url, content_type, description, created_at",
)
.bind(&body.key)
.bind(&body.url)
.bind(&body.content_type)
.bind(&body.description)
.fetch_one(&state.pool)
.await
.map_err(|e| {
tracing::error!("cdn_create: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(obj))
}
pub async fn cdn_update(
State(state): State<AppState>,
Path(key): Path<String>,
Json(body): Json<UpdateCdnObject>,
) -> Result<Json<CdnObject>, StatusCode> {
let obj = sqlx::query_as::<_, CdnObject>(
"UPDATE cdn_objects \
SET key = COALESCE($2, key), \
url = COALESCE($3, url), \
content_type = COALESCE($4, content_type), \
description = COALESCE($5, description) \
WHERE key = $1 \
RETURNING id, key, url, content_type, description, created_at",
)
.bind(&key)
.bind(&body.key)
.bind(&body.url)
.bind(&body.content_type)
.bind(&body.description)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(obj))
}
pub async fn cdn_delete(
State(state): State<AppState>,
Path(key): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let rows = sqlx::query("DELETE FROM cdn_objects WHERE key = $1")
.bind(&key)
.execute(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.rows_affected();
if rows == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(serde_json::json!({ "deleted": true })))
}
- Step 2: Add pub mod cdn to src/routes/mod.rs
pub mod admin;
pub mod auth;
pub mod cdn;
pub mod crud;
pub fn is_valid_identifier(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
}
- Step 3: Verify compilation
cd /home/mcpeakml/code/rust/Mercury && cargo build 2>&1 | tail -3
Expected: Finished dev
Task 6: reload_cors Hook + Protected Tables
Files:
- Modify:
src/routes/crud.rs - Modify:
src/routes/admin/tables.rs
Interfaces:
-
Consumes:
AppState.cors_cache: CorsCache -
Produces:
reload_corscalled after every non-GET mutation tocors_originstable -
Step 1: Add reload_cors to src/routes/crud.rs
Add after the existing reload_blacklist function (around line 193):
async fn reload_cors(state: &AppState) -> Result<(), StatusCode> {
let origins: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT origin FROM cors_origins ORDER BY id",
)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state.cors_cache.load(origins).await;
Ok(())
}
- Step 2: Add cors_origins hook in handle_crud
Find the block starting at line 335 (the blacklist reload hook) and update it to:
// Reload in-memory caches after mutations to their backing tables.
if table == "blacklist" && method_str != "GET" {
reload_blacklist(&state).await?;
}
if table == "cors_origins" && method_str != "GET" {
reload_cors(&state).await?;
}
- Step 3: Add cors_origins + cdn_objects to PROTECTED_TABLES in src/routes/admin/tables.rs
Find line 27:
const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"];
Replace with:
const PROTECTED_TABLES: &[&str] = &[
"users", "blacklist", "api_keys", "queries", "permissions",
"cors_origins", "cdn_objects",
];
- Step 4: Run tests
cd /home/mcpeakml/code/rust/Mercury && cargo test 2>&1 | tail -5
Expected: all tests pass.
Task 7: main.rs Wiring
Files:
- Modify:
src/main.rs
Interfaces:
-
Consumes:
CorsCache,cors_layer,cdn_list,cdn_proxy,cdn_create,cdn_update,cdn_delete -
Produces: running server with dynamic CORS + CDN proxy routes
-
Step 1: Update imports in src/main.rs
Replace the current imports block with:
mod auth;
mod cache;
mod config;
mod db;
mod models;
mod routes;
mod state;
use std::io::{self, Write};
use std::sync::Arc;
use axum::{
extract::DefaultBodyLimit,
middleware,
routing::{delete, get, post, put},
Router,
};
use tower_http::services::{ServeDir, ServeFile};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
auth::middleware::{blacklist_layer, cors_layer, require_auth},
cache::spawn_sweep_task,
config::Config,
db::create_pool,
models::blacklist::BlacklistEntry,
routes::{
admin::admin_router,
auth::login,
cdn::{cdn_create, cdn_delete, cdn_list, cdn_proxy, cdn_update},
crud::handle_crud,
},
state::{AppState, BlacklistCache, CorsCache, QueryCache},
};
- Step 2: Remove build_cors function
Delete the entire fn build_cors(origins: &[String]) -> CorsLayer function (lines 35–53 in the original file).
- Step 3: Add cors_origins seeding + cors_cache + http_client after the blacklist load block
Find the existing blacklist load block (around line 127) and add after blacklist_cache.load(entries).await;:
// Load CORS origins; seed from CORS_ORIGINS env var if table is empty.
let cors_count: i64 = sqlx::query_scalar::<_, Option<i64>>("SELECT COUNT(*) FROM cors_origins")
.fetch_one(&pool)
.await?
.unwrap_or(0);
if cors_count == 0 && !config.cors_origins.is_empty() {
for origin in &config.cors_origins {
sqlx::query(
"INSERT INTO cors_origins (origin) VALUES ($1) ON CONFLICT DO NOTHING",
)
.bind(origin)
.execute(&pool)
.await?;
}
tracing::info!(
"seeded {} CORS origin(s) from CORS_ORIGINS env var",
config.cors_origins.len()
);
}
let cors_origins: Vec<String> =
sqlx::query_scalar::<_, String>("SELECT origin FROM cors_origins ORDER BY id")
.fetch_all(&pool)
.await?;
let cors_cache = CorsCache::new();
cors_cache.load(cors_origins).await;
let http_client = reqwest::Client::new();
- Step 4: Update AppState construction
Find let state = AppState { ... } and update to:
let state = AppState {
pool,
query_cache,
blacklist_cache,
cors_cache,
http_client,
config: config.clone(),
};
- Step 5: Update router construction
Replace the entire let crud_routes = ..., let cors_layer = ..., and let app = ... block with:
let crud_routes = Router::new()
.route("/api/:table", get(handle_crud).post(handle_crud))
.route("/api/:table/", get(handle_crud).post(handle_crud))
.route(
"/api/:table/:id",
get(handle_crud).put(handle_crud).delete(handle_crud),
)
.route(
"/api/:table/:id/",
get(handle_crud).put(handle_crud).delete(handle_crud),
)
.layer(DefaultBodyLimit::max(1024 * 1024))
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
.route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer));
// CDN routes: GET is public; POST/PUT/DELETE are blacklisted for non-super-admins.
let cdn_routes = Router::new()
.route("/api/cdn", get(cdn_list).post(cdn_create))
.route("/api/cdn/:key", get(cdn_proxy).put(cdn_update).delete(cdn_delete))
.route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer));
let app = Router::new()
.route("/auth/login", post(login))
.merge(cdn_routes)
.merge(crud_routes)
.nest("/api/admin", admin_router(state.clone()))
.nest_service(
"/",
ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")),
)
.layer(middleware::from_fn_with_state(state.clone(), cors_layer))
.with_state(state);
Note: cdn_routes is merged before crud_routes so /api/cdn literal paths take precedence over the /api/:table parameterised CRUD routes.
- Step 6: Run full test suite
cd /home/mcpeakml/code/rust/Mercury && cargo test 2>&1 | tail -5
Expected: all tests pass.
Task 8: Spinner CSS
Files:
- Modify:
ui/src/assets/main.css
Interfaces:
-
Produces:
.loading-overlayand.loading-spinnerclasses available globally to all Vue views -
Step 1: Append spinner styles to ui/src/assets/main.css
Add at the end of the file:
/* ── Loading spinner ─────────────────────────────────────── */
@keyframes mercury-spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: mercury-spin 0.7s linear infinite;
flex-shrink: 0;
}
.loading-overlay {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 3rem 2rem;
color: var(--text-muted);
font-size: 0.85rem;
font-family: var(--font-sans);
}
Task 9: Loading State — Existing Admin Views
Files:
- Modify:
ui/src/views/admin/Blacklist.vue - Modify:
ui/src/views/admin/ApiKeys.vue - Modify:
ui/src/views/admin/Cache.vue - Modify:
ui/src/views/admin/Queries.vue - Modify:
ui/src/views/admin/Tables.vue - Modify:
ui/src/views/admin/Users.vue - Modify:
ui/src/views/admin/Permissions.vue
Interfaces:
- Consumes:
.loading-overlay+.loading-spinnerfrommain.css
9a — Blacklist.vue
- Step 1: Add loading ref to script
In the <script setup> block, after const form = ref(...), add:
const loading = ref(false)
- Step 2: Wrap load() with loading state
Replace:
async function load() {
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
entries.value = await res.json()
}
With:
async function load() {
loading.value = true
try {
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
entries.value = await res.json()
} finally {
loading.value = false
}
}
- Step 3: Add spinner to template
In the <div class="table-card">, insert the loading overlay immediately after <div class="table-card-header">...</div>:
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
Also change <template v-if="entries.length"> to <template v-else-if="entries.length"> and <div class="empty-state" v-else> stays as-is (it will only show if not loading and no entries).
9b — ApiKeys.vue
- Step 1: Add loading ref
In <script setup>, after const form = ref(...), add:
const loading = ref(false)
- Step 2: Wrap load()
Replace:
async function load() {
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
keys.value = await res.json()
}
With:
async function load() {
loading.value = true
try {
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
keys.value = await res.json()
} finally {
loading.value = false
}
}
- Step 3: Add spinner to template
After <div class="table-card-header">...</div>, insert:
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
Change <template v-if="keys.length"> → <template v-else-if="keys.length">.
9c — Cache.vue
- Step 1: Add loading ref
In <script setup>, add after const stats = ref(...):
const loading = ref(false)
- Step 2: Wrap load()
Replace:
async function load() {
const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() })
stats.value = await res.json()
}
With:
async function load() {
loading.value = true
try {
const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() })
stats.value = await res.json()
} finally {
loading.value = false
}
}
- Step 3: Add spinner to template
Change the existing template block:
<div class="stats-grid" v-if="stats">
...
</div>
<div class="no-stats" v-else>
...
</div>
To:
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
<div class="stats-grid" v-else-if="stats">
...
</div>
<div class="no-stats" v-else>
...
</div>
9d — Queries.vue
- Step 1: Add loading ref
In <script setup>, after const editForm = ref(...), add:
const loading = ref(false)
- Step 2: Wrap load()
Replace:
async function load() {
const res = await fetch('/api/queries', { headers: auth.authHeaders() })
queries.value = await res.json()
}
With:
async function load() {
loading.value = true
try {
const res = await fetch('/api/queries', { headers: auth.authHeaders() })
queries.value = await res.json()
} finally {
loading.value = false
}
}
- Step 3: Add spinner to template
After <div class="table-card-header">...</div>, insert:
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
Change <template v-if="queries.length"> → <template v-else-if="queries.length">.
9e — Tables.vue
- Step 1: Add loading ref
In <script setup>, after the existing ref declarations, add:
const loading = ref(false)
- Step 2: Find the load() function for tables and wrap it
Find async function load() (the one that fetches /api/admin/tables) and replace:
async function load() {
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
tables.value = await res.json()
}
With:
async function load() {
loading.value = true
try {
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
tables.value = await res.json()
} finally {
loading.value = false
}
}
- Step 3: Add spinner to template
After <div class="table-card-header">...</div>, insert:
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
Change <template v-if="tables.length"> → <template v-else-if="tables.length">.
9f — Users.vue
- Step 1: Add loading ref
In <script setup>, after const form = ref(...), add:
const loading = ref(false)
- Step 2: Wrap load()
Replace:
async function load() {
const res = await fetch('/api/users', { headers: auth.authHeaders() })
users.value = (await res.json()).filter((u: any) => !u.password_hash)
}
Check the exact fetch URL by reading the file. Wrap it:
async function load() {
loading.value = true
try {
const res = await fetch('/api/users', { headers: auth.authHeaders() })
users.value = (await res.json()).filter((u: any) => !u.password_hash)
} finally {
loading.value = false
}
}
- Step 3: Add spinner to template
After <div class="table-card-header">...</div>, insert:
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
Change <template v-if="users.length"> → <template v-else-if="users.length">.
9g — Permissions.vue
- Step 1: Add loading ref
In <script setup>, after const form = ref(...), add:
const loading = ref(false)
- Step 2: Wrap load()
Replace:
async function load() {
const res = await fetch('/api/permissions', { headers: auth.authHeaders() })
permissions.value = await res.json()
}
With:
async function load() {
loading.value = true
try {
const res = await fetch('/api/permissions', { headers: auth.authHeaders() })
permissions.value = await res.json()
} finally {
loading.value = false
}
}
- Step 3: Add spinner to template
After <div class="table-card-header">...</div>, insert:
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
Change <template v-if="permissions.length"> → <template v-else-if="permissions.length">.
Task 10: Cors.vue
Files:
- Create:
ui/src/views/admin/Cors.vue
Interfaces:
-
Consumes:
GET /api/cors_origins,POST /api/cors_origins,DELETE /api/cors_origins/:id -
Step 1: Create ui/src/views/admin/Cors.vue
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>CORS Origins</h2>
<span class="subtitle">Allowed cross-origin request sources — matched against the request Origin header</span>
</div>
<NychButton @click="showCreate = true" label="+ Add Origin" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ origins.length }} {{ origins.length === 1 ? 'origin' : 'origins' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
<template v-else-if="origins.length">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Origin</th>
<th>Added</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in origins" :key="o.id">
<td><code>{{ o.origin }}</code></td>
<td class="date-cell">{{ fmtDate(o.created_at) }}</td>
<td class="actions-cell">
<NychButton size="small" severity="danger" @click="deleteOrigin(o.id)" label="Delete" />
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div class="empty-state" v-else>
<span class="empty-icon">✦</span>
<span class="empty-label">No origins configured</span>
<span class="empty-hint">Cross-origin requests will be rejected until an origin is added.</span>
</div>
</div>
<NychDialog v-model:visible="showCreate" header="Add CORS Origin" :modal="true" :draggable="false" style="width: min(480px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Origin</label>
<NychInputText v-model="form.origin" placeholder="https://app.example.com" fluid />
<p class="hint">Use <code>*</code> to allow all origins (permissive mode).</p>
</div>
<NychButton type="submit" fluid label="Add Origin" :disabled="!form.origin || loading" />
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const origins = ref<any[]>([])
const showCreate = ref(false)
const loading = ref(false)
const form = ref({ origin: '' })
function fmtDate(s: string) {
return new Date(s).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
async function load() {
loading.value = true
try {
const res = await fetch('/api/cors_origins', { headers: auth.authHeaders() })
origins.value = await res.json()
} finally {
loading.value = false
}
}
async function submitCreate() {
loading.value = true
try {
await fetch('/api/cors_origins', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ origin: form.value.origin }),
})
showCreate.value = false
form.value = { origin: '' }
await load()
} finally {
loading.value = false
}
}
async function deleteOrigin(id: number) {
if (!confirm('Remove this CORS origin?')) return
loading.value = true
try {
await fetch(`/api/cors_origins/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
await load()
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.date-cell { color: var(--text-muted); font-size: 0.85rem; }
.hint { font-size: 0.78rem; color: var(--text-dim); margin: 0.25rem 0 0; font-family: var(--font-sans); }
</style>
Task 11: Cdn.vue
Files:
- Create:
ui/src/views/admin/Cdn.vue
Interfaces:
-
Consumes:
GET /api/cdn,POST /api/cdn,PUT /api/cdn/:key,DELETE /api/cdn/:key -
Step 1: Create ui/src/views/admin/Cdn.vue
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>CDN Objects</h2>
<span class="subtitle">Object storage registry — keys proxied through the API to the local CDN</span>
</div>
<NychButton @click="showCreate = true" label="+ Add Object" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ objects.length }} {{ objects.length === 1 ? 'object' : 'objects' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading…</span>
</div>
<template v-else-if="objects.length">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Key</th>
<th>URL</th>
<th>Content Type</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in objects" :key="o.id">
<td><code>{{ o.key }}</code></td>
<td class="url-cell"><code>{{ o.url }}</code></td>
<td class="ct-cell">{{ o.content_type ?? '—' }}</td>
<td class="desc-cell">{{ o.description ?? '—' }}</td>
<td class="actions-cell">
<NychButton size="small" @click="openEdit(o)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteObject(o.key)" label="Delete" />
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div class="empty-state" v-else>
<span class="empty-icon">▣</span>
<span class="empty-label">No CDN objects</span>
<span class="empty-hint">Add objects to expose local CDN assets through the API.</span>
</div>
</div>
<NychDialog v-model:visible="showCreate" header="Add CDN Object" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Key</label>
<NychInputText v-model="form.key" placeholder="logo.png" fluid />
<p class="hint">Accessed at <code>/api/cdn/<key></code></p>
</div>
<div class="field">
<label>URL</label>
<NychInputText v-model="form.url" placeholder="http://cdn-service:9000/bucket/logo.png" fluid />
<p class="hint">Internal URL the API will proxy to.</p>
</div>
<div class="field">
<label>Content Type <span class="optional">(optional)</span></label>
<NychInputText v-model="form.content_type" placeholder="image/png" fluid />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="form.description" placeholder="App logo" fluid />
</div>
<NychButton type="submit" fluid label="Add Object" :disabled="!form.key || !form.url || loading" />
</form>
</NychDialog>
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editKey}`" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Key</label>
<NychInputText v-model="editForm.key" fluid />
</div>
<div class="field">
<label>URL</label>
<NychInputText v-model="editForm.url" fluid />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.content_type" fluid />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.description" fluid />
</div>
<NychButton type="submit" fluid label="Save Changes" :disabled="loading" />
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const objects = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editKey = ref('')
const loading = ref(false)
const form = ref({ key: '', url: '', content_type: '', description: '' })
const editForm = ref({ key: '', url: '', content_type: '', description: '' })
async function load() {
loading.value = true
try {
const res = await fetch('/api/cdn', { headers: auth.authHeaders() })
objects.value = await res.json()
} finally {
loading.value = false
}
}
async function submitCreate() {
loading.value = true
try {
await fetch('/api/cdn', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
key: form.value.key,
url: form.value.url,
content_type: form.value.content_type || null,
description: form.value.description || null,
}),
})
showCreate.value = false
form.value = { key: '', url: '', content_type: '', description: '' }
await load()
} finally {
loading.value = false
}
}
function openEdit(o: any) {
editKey.value = o.key
editForm.value = {
key: o.key,
url: o.url,
content_type: o.content_type ?? '',
description: o.description ?? '',
}
showEdit.value = true
}
async function submitEdit() {
loading.value = true
try {
await fetch(`/api/cdn/${editKey.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
key: editForm.value.key || null,
url: editForm.value.url || null,
content_type: editForm.value.content_type || null,
description: editForm.value.description || null,
}),
})
showEdit.value = false
await load()
} finally {
loading.value = false
}
}
async function deleteObject(key: string) {
if (!confirm(`Delete CDN object "${key}"?`)) return
loading.value = true
try {
await fetch(`/api/cdn/${key}`, { method: 'DELETE', headers: auth.authHeaders() })
await load()
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.url-cell { font-size: 0.8rem; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ct-cell { color: var(--text-muted); font-size: 0.85rem; font-family: var(--font-mono); }
.desc-cell { color: var(--text-muted); font-size: 0.85rem; }
.hint { font-size: 0.78rem; color: var(--text-dim); margin: 0.25rem 0 0; font-family: var(--font-sans); }
</style>
Task 12: Router + Nav Updates
Files:
- Modify:
ui/src/router/index.ts - Modify:
ui/src/views/admin/Layout.vue
Interfaces:
-
Produces:
/admin/corsand/admin/cdnrouted and visible in sidebar -
Step 1: Add routes to ui/src/router/index.ts
Replace the children array:
children: [
{ path: 'queries', component: () => import('../views/admin/Queries.vue') },
{ path: 'tables', component: () => import('../views/admin/Tables.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: 'api-keys', component: () => import('../views/admin/ApiKeys.vue') },
{ path: 'cache', component: () => import('../views/admin/Cache.vue') },
{ path: 'cors', component: () => import('../views/admin/Cors.vue') },
{ path: 'cdn', component: () => import('../views/admin/Cdn.vue') },
],
- Step 2: Add nav items to Layout.vue
Replace the navItems array:
const navItems = [
{ to: "/admin/queries", label: "Queries", icon: "⌗" },
{ to: "/admin/tables", label: "Tables", icon: "▦" },
{ to: "/admin/users", label: "Users", icon: "◉" },
{ to: "/admin/permissions", label: "Permissions", icon: "⬡" },
{ to: "/admin/blacklist", label: "Blacklist", icon: "⊘" },
{ to: "/admin/api-keys", label: "API Keys", icon: "⚿" },
{ to: "/admin/cache", label: "Cache", icon: "◈" },
{ to: "/admin/cors", label: "CORS", icon: "✦" },
{ to: "/admin/cdn", label: "CDN Objects", icon: "▣" },
];
- Step 3: Build the frontend
cd /home/mcpeakml/code/rust/Mercury/ui && bun run build 2>&1 | tail -5
Expected: dist/ rebuilt successfully with no errors.
Self-Review
Spec coverage check:
- ✅ CORS origins stored in DB (
cors_originstable) — Task 1 - ✅ CORS in-memory cache (
CorsCache) — Task 2 - ✅ Custom async
cors_layermiddleware (replaces staticCorsLayer) — Task 4 - ✅ CORS reload after CRUD mutation — Task 6
- ✅ CORS_ORIGINS env var seeds DB on first startup — Task 7
- ✅ CDN DB table (
cdn_objects) — Task 1 - ✅ CDN proxy handler (
cdn_proxy) — Task 5 - ✅ CDN CRUD handlers — Task 5
- ✅ CDN blacklist for mutations (POST/PUT/DELETE), GET unblocked — Task 1 + Task 7
- ✅
cors_origins+cdn_objectsin PROTECTED_TABLES (drop-safe) — Task 6 - ✅ Spinner CSS — Task 8
- ✅ Loading state across 7 existing views — Task 9
- ✅ Cors.vue admin view — Task 10
- ✅ Cdn.vue admin view — Task 11
- ✅ Router + nav for both views — Task 12
Type consistency check:
CorsCachedefined in Task 2, consumed in Task 4 (middleware) and Task 6 (reload_cors) and Task 7 (AppState init) — consistent.CdnObject,CreateCdnObject,UpdateCdnObjectdefined in Task 3, consumed in Task 5 — consistent.cdn_list,cdn_proxy,cdn_create,cdn_update,cdn_deletedefined in Task 5, imported in Task 7 — consistent.cors_layerdefined in Task 4, imported in Task 7 — consistent.AppState.http_client: reqwest::Clientadded in Task 2, used in Task 5 (state.http_client) — consistent.- Vue views use
GET /api/cdn(not/api/cdn_objects) for list — matches CDN route defined in Task 7. - Vue Cors.vue uses
GET /api/cors_origins— matches CRUD route (blacklist allows super_admin GET via bypass_mask) — consistent.