7 KiB
Mercury — Dynamic CORS Design Spec
Date: 2026-06-18
Overview
Replace the static startup-time CorsLayer with a DB-backed, in-memory CORS cache that can be edited at runtime through the standard CRUD API, mirroring the existing route blacklist pattern. Add a Vue admin view for managing origins and add loading state indicators across all admin views.
Motivation
CORS origins are currently loaded from the CORS_ORIGINS environment variable at server startup and baked into a static tower_http::CorsLayer. Changing origins requires a server restart. This feature makes CORS origins a first-class runtime configuration: stored in PostgreSQL, held in an in-memory cache, and editable by super-admins through the standard CRUD API without downtime.
Data Layer
Migration 007_cors_origins.sql
Create a cors_origins table:
CREATE TABLE cors_origins (
id SERIAL PRIMARY KEY,
origin TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Seed blacklist entries to restrict CRUD access to super-admins only (SUPER_ADMIN bit = 32), matching the existing pattern for blacklist, queries, users, and permissions:
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');
Startup Seeding
On server startup, if the cors_origins table is empty and the CORS_ORIGINS environment variable is set, seed the table from the env var (splitting on ,, trimming whitespace). This provides a smooth migration path from the env-var approach. Once the DB has entries, CORS_ORIGINS is ignored.
In-Memory Cache
CorsCache in state.rs
pub struct CorsCache {
pub inner: Arc<RwLock<CorsState>>,
}
pub struct CorsState {
pub wildcard: bool,
pub origins: Vec<HeaderValue>,
}
CorsCache::load(origins: Vec<String>):
- Sets
wildcard = trueif any origin is"*" - Parses each string into a
HeaderValue, skipping malformed entries - Acquires a write lock and replaces the inner state
AppState gains a cors_cache: CorsCache field alongside blacklist_cache.
Middleware
cors_layer in auth/middleware.rs
An async Axum middleware (from_fn_with_state) that runs on every request. Behavior:
| Cache state | Request type | Action |
|---|---|---|
| Empty (no origins, no wildcard) | Any | Pass through — no CORS headers added |
Wildcard (*) |
Non-OPTIONS | Add Access-Control-Allow-Origin: * to response |
Wildcard (*) |
OPTIONS preflight | Return 204 with Access-Control-Allow-Origin: *, Access-Control-Allow-Methods, Access-Control-Allow-Headers |
| Specific origins | Non-OPTIONS, Origin matches | Echo origin back as Access-Control-Allow-Origin, add Vary: Origin |
| Specific origins | Non-OPTIONS, Origin no match | Pass through — no CORS headers |
| Specific origins | OPTIONS preflight, Origin matches | Return 204 with echoed origin, Vary: Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers |
| Specific origins | OPTIONS preflight, Origin no match | Return 204 with no CORS headers |
Allowed methods: GET, POST, PUT, DELETE, OPTIONS.
Allowed headers: Content-Type, Authorization.
The middleware is applied globally via .layer(middleware::from_fn_with_state(state.clone(), cors_layer)) in main.rs, replacing the removed build_cors() call and static CorsLayer.
reload_cors in routes/crud.rs
Called after any non-GET mutation to the cors_origins table, exactly mirroring reload_blacklist:
async fn reload_cors(state: &AppState) -> Result<(), StatusCode> {
// SELECT origin FROM cors_origins ORDER BY id
// state.cors_cache.load(origins).await
}
The existing handle_crud hook block gains a cors_origins branch alongside the blacklist branch.
Admin Interface
Cors.vue
New Vue view at ui/src/views/admin/Cors.vue:
- Header: "CORS Origins" with subtitle "Allowed cross-origin request sources — matched against the request Origin header"
- Table card: lists all origins with
id,origin(monospace),created_at, and aDeletebutton per row - Create dialog: single
Origintext input with placeholderhttps://app.example.comor*, and anAdd Originsubmit button - Empty state: icon + "No origins configured — cross-origin requests will be rejected" label + hint
- API calls:
GET /api/cors_origins(list),POST /api/cors_origins(create),DELETE /api/cors_origins/:id(remove)
Router & Navigation
- Route added to
ui/src/router/index.ts:{ path: 'cors', component: () => import('../views/admin/Cors.vue') } - Nav item added to
Layout.vuenavItems:{ to: '/admin/cors', label: 'CORS', icon: '✦' }
Loading State (All Admin Views)
A consistent loading indicator is added to every admin view (Queries.vue, Tables.vue, Users.vue, Permissions.vue, Blacklist.vue, ApiKeys.vue, Cache.vue, and the new Cors.vue).
Pattern
Each view gains a loading ref:
const loading = ref(false)
async function load() {
loading.value = true
try {
// existing fetch logic
} finally {
loading.value = false
}
}
Mutating operations (submitCreate, submitEdit, deleteEntry, etc.) also set loading = true for their duration.
Shared CSS Spinner
A .spinner keyframe animation added once to main.css:
@keyframes 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: spin 0.7s linear infinite;
}
.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);
}
Each view shows a <div class="loading-overlay"><div class="loading-spinner"></div><span>Loading…</span></div> in place of the table/content when loading is true.
Files Changed
| File | Change |
|---|---|
src/db/migrations/007_cors_origins.sql |
New migration |
src/state.rs |
Add CorsCache, CorsState; add cors_cache to AppState |
src/auth/middleware.rs |
Add cors_layer middleware |
src/routes/crud.rs |
Add reload_cors, hook into handle_crud |
src/main.rs |
Remove build_cors, remove static CorsLayer, seed cors_origins from env on startup, apply cors_layer |
ui/src/views/admin/Cors.vue |
New view |
ui/src/router/index.ts |
Add /admin/cors route |
ui/src/views/admin/Layout.vue |
Add CORS nav item |
ui/src/assets/main.css |
Add spinner CSS |
ui/src/views/admin/*.vue (all 7 existing) |
Add loading ref and spinner display |
Out of Scope
- Credentials support (
Access-Control-Allow-Credentials) — not currently used - Per-origin method/header overrides — uniform allow-list for all origins
- Max-age preflight caching header — can be added later