Mercury/docs/superpowers/plans/2026-06-18-cors-cdn-implementation.md
Matthew L McPeak a202a06562
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 3m13s
ci / publish (push) Successful in 2m55s
Inital Commit
2026-06-18 19:24:40 -04:00

47 KiB
Raw Blame History

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 (not sqlx::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 mod declaration in their parent mod.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 test must 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.ymlcdn (MinIO) + cdn-init services; cdn_data volume; api depends_on cdn healthy
  • Dockerfileminio-download stage (alpine:3, downloads minio + mc via TARGETARCH); copies binaries to final image; exposes 9000/9001
  • entrypoint.sh — starts MinIO in background, loops mc alias set until ready, creates $CDN_BUCKET with anonymous download policy, then starts Postgres + Mercury
  • .env.example — documents CDN_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) and cdn_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() -> CorsCache
    • CorsCache::load(origins: Vec<String>) -> () (async)
    • AppState.cors_cache: CorsCache
    • AppState.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 by routes/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 in main.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 in main.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_cors called after every non-GET mutation to cors_origins table

  • 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 3553 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-overlay and .loading-spinner classes 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-spinner from main.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/&lt;key&gt;</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/cors and /admin/cdn routed 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_origins table) — Task 1
  • CORS in-memory cache (CorsCache) — Task 2
  • Custom async cors_layer middleware (replaces static CorsLayer) — 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_objects in 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:

  • CorsCache defined in Task 2, consumed in Task 4 (middleware) and Task 6 (reload_cors) and Task 7 (AppState init) — consistent.
  • CdnObject, CreateCdnObject, UpdateCdnObject defined in Task 3, consumed in Task 5 — consistent.
  • cdn_list, cdn_proxy, cdn_create, cdn_update, cdn_delete defined in Task 5, imported in Task 7 — consistent.
  • cors_layer defined in Task 4, imported in Task 7 — consistent.
  • AppState.http_client: reqwest::Client added 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.