Mercury/docs/superpowers/specs/2026-06-18-cors-dynamic-design.md
Matthew L McPeak 7e9bd0b55d
Some checks failed
ci / test (push) Failing after 8s
ci / build-ui (push) Successful in 12s
ci / publish (push) Has been skipped
Inital Commit
2026-06-19 06:02:08 -04:00

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 = 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:

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:

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