# 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>` 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.yml` — `cdn` (MinIO) + `cdn-init` services; `cdn_data` volume; `api` `depends_on` cdn healthy - `Dockerfile` — `minio-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/` - standalone image: `http://localhost:9000/mercury/` 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** ```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** ```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** ```bash 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) -> ()` (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: ```toml 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`: ```rust #[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** ```bash 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: ```rust use axum::http::HeaderValue; ``` Add after the `BlacklistCache` impl block: ```rust #[derive(Clone)] pub struct CorsCache { pub inner: Arc>, } #[derive(Clone, Default)] pub struct CorsState { pub wildcard: bool, pub origins: Vec, } impl CorsCache { pub fn new() -> Self { Self { inner: Arc::new(RwLock::new(CorsState::default())), } } pub async fn load(&self, origins: Vec) { let wildcard = origins.iter().any(|o| o == "*"); let parsed: Vec = 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`: ```rust #[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, } ``` - [ ] **Step 5: Run tests to verify they pass** ```bash 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** ```rust 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, pub description: Option, pub created_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateCdnObject { pub key: String, pub url: String, pub content_type: Option, pub description: Option, } #[derive(Debug, Deserialize)] pub struct UpdateCdnObject { pub key: Option, pub url: Option, pub content_type: Option, pub description: Option, } ``` - [ ] **Step 2: Add to src/models/mod.rs** ```rust pub mod api_key; pub mod blacklist; pub mod cdn; pub mod permission; pub mod query; pub mod user; ``` - [ ] **Step 3: Verify compilation** ```bash 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, Request, Next) -> Response` — applied globally in `main.rs` - [ ] **Step 1: Update imports in src/auth/middleware.rs** Replace the existing imports block with: ```rust 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: ```rust pub async fn cors_layer( State(state): State, 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** ```bash 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** ```rust 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, ) -> Result>, 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, Path(key): Path, ) -> Result { 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 = row.try_get::, _>("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, Json(body): Json, ) -> Result, 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, Path(key): Path, Json(body): Json, ) -> Result, 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, Path(key): Path, ) -> Result, 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** ```rust 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** ```bash 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): ```rust async fn reload_cors(state: &AppState) -> Result<(), StatusCode> { let origins: Vec = 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: ```rust // 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: ```rust const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"]; ``` Replace with: ```rust const PROTECTED_TABLES: &[&str] = &[ "users", "blacklist", "api_keys", "queries", "permissions", "cors_origins", "cdn_objects", ]; ``` - [ ] **Step 4: Run tests** ```bash 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: ```rust 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;`: ```rust // Load CORS origins; seed from CORS_ORIGINS env var if table is empty. let cors_count: i64 = sqlx::query_scalar::<_, Option>("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 = 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: ```rust 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: ```rust 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** ```bash 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: ```css /* ── 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 ` ``` --- ### 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** ```vue ``` --- ### 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: ```ts 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: ```ts 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** ```bash 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.