# 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: ```sql 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`: ```sql 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` ```rust pub struct CorsCache { pub inner: Arc>, } pub struct CorsState { pub wildcard: bool, pub origins: Vec, } ``` `CorsCache::load(origins: Vec)`: - Sets `wildcard = true` if 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`: ```rust 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 a `Delete` button per row - **Create dialog:** single `Origin` text input with placeholder `https://app.example.com` or `*`, and an `Add Origin` submit 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.vue` `navItems`: `{ 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: ```ts 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`: ```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 `
Loading…
` 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