Mercury/docs/superpowers/plans/2026-06-18-cors-cdn-implementation.md
Matthew L McPeak dd700d02ad
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 3m50s
ci / publish (push) Successful in 3m9s
Inital Commit
2026-07-16 12:36:14 -04:00

1638 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.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/<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**
```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<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:
```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<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`:
```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<Config>,
}
```
- [ ] **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<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**
```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<AppState>, 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<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**
```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<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**
```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<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:
```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 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;`:
```rust
// 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:
```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 `<script setup>` block, after `const form = ref(...)`, add:
```ts
const loading = ref(false)
```
- [ ] **Step 2: Wrap load() with loading state**
Replace:
```ts
async function load() {
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
entries.value = await res.json()
}
```
With:
```ts
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>`:
```html
<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:
```ts
const loading = ref(false)
```
- [ ] **Step 2: Wrap load()**
Replace:
```ts
async function load() {
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
keys.value = await res.json()
}
```
With:
```ts
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:
```html
<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(...)`:
```ts
const loading = ref(false)
```
- [ ] **Step 2: Wrap load()**
Replace:
```ts
async function load() {
const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() })
stats.value = await res.json()
}
```
With:
```ts
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:
```html
<div class="stats-grid" v-if="stats">
...
</div>
<div class="no-stats" v-else>
...
</div>
```
To:
```html
<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:
```ts
const loading = ref(false)
```
- [ ] **Step 2: Wrap load()**
Replace:
```ts
async function load() {
const res = await fetch('/api/queries', { headers: auth.authHeaders() })
queries.value = await res.json()
}
```
With:
```ts
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:
```html
<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:
```ts
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:
```ts
async function load() {
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
tables.value = await res.json()
}
```
With:
```ts
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:
```html
<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:
```ts
const loading = ref(false)
```
- [ ] **Step 2: Wrap load()**
Replace:
```ts
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:
```ts
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:
```html
<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:
```ts
const loading = ref(false)
```
- [ ] **Step 2: Wrap load()**
Replace:
```ts
async function load() {
const res = await fetch('/api/permissions', { headers: auth.authHeaders() })
permissions.value = await res.json()
}
```
With:
```ts
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:
```html
<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**
```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**
```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:
```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.