Mercury/docs/superpowers/specs/2026-06-18-cors-dynamic-design.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

199 lines
7 KiB
Markdown

# 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<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`:
```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 `<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