620 lines
21 KiB
Markdown
620 lines
21 KiB
Markdown
# DISABLE_AUTH Env Var 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 a `DISABLE_AUTH` environment variable that, when set, makes the Mercury API treat every request as an authenticated super-admin (no token required) and makes the Vue admin UI skip the login screen entirely.
|
|
|
|
**Architecture:** A new `Config.disable_auth` bool gates a single new helper, `authenticate_or_bypass`, that all auth-checking middleware (`require_auth`, `require_super_admin`, `require_admin_query`, `require_admin_cache`, `blacklist_layer`) call instead of manually extracting a bearer token. When bypassing, it injects a synthetic super-admin `Claims`. A new public `GET /auth/config` endpoint exposes the flag so the frontend's Pinia auth store can short-circuit `isAuthenticated`/`isSuperAdmin` and the router guard never redirects to `/login`.
|
|
|
|
**Tech Stack:** Rust/Axum/SQLx backend, Vue 3 + Pinia + vue-router frontend.
|
|
|
|
## Global Constraints
|
|
|
|
- Default `DISABLE_AUTH` to off/false — the app must behave exactly as today when it's unset.
|
|
- Truthy values are `1` and `true` (case-insensitive); anything else (including unset) is false.
|
|
- The bypass is all-or-nothing: no partial/route-scoped bypass.
|
|
- Follow existing project test conventions: this codebase only unit-tests pure functions (no DB-backed router/handler tests exist anywhere in `src/`) — do not introduce a new DB-mocking pattern; where a change touches a real `PgPool`-holding handler with no pure logic to extract, verify manually instead of adding a test.
|
|
- No commits during this implementation — leave changes in the working tree.
|
|
|
|
---
|
|
|
|
### Task 1: `Config.disable_auth`
|
|
|
|
**Files:**
|
|
- Modify: `src/config.rs` (struct fields ~lines 3-17, `from_env` body ~lines 20-43, `tests` mod ~line 50+)
|
|
|
|
**Interfaces:**
|
|
- Produces: `Config.disable_auth: bool` — read by Task 2's middleware and Task 3's `/auth/config` handler.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to the `tests` mod at the bottom of `src/config.rs`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn test_config_disable_auth_default_false() {
|
|
std::env::set_var("DATABASE_URL", "postgres://test");
|
|
std::env::set_var("JWT_SECRET", "secret");
|
|
std::env::remove_var("DISABLE_AUTH");
|
|
let cfg = Config::from_env().unwrap();
|
|
assert!(!cfg.disable_auth);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_disable_auth_true() {
|
|
std::env::set_var("DATABASE_URL", "postgres://test");
|
|
std::env::set_var("JWT_SECRET", "secret");
|
|
std::env::set_var("DISABLE_AUTH", "true");
|
|
let cfg = Config::from_env().unwrap();
|
|
assert!(cfg.disable_auth);
|
|
std::env::remove_var("DISABLE_AUTH");
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_disable_auth_numeric_true() {
|
|
std::env::set_var("DATABASE_URL", "postgres://test");
|
|
std::env::set_var("JWT_SECRET", "secret");
|
|
std::env::set_var("DISABLE_AUTH", "1");
|
|
let cfg = Config::from_env().unwrap();
|
|
assert!(cfg.disable_auth);
|
|
std::env::remove_var("DISABLE_AUTH");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `cargo test --lib config::tests`
|
|
Expected: FAIL — `no field \`disable_auth\` on type \`Config\`` (compile error)
|
|
|
|
- [ ] **Step 3: Implement the field and parsing**
|
|
|
|
In `src/config.rs`, add the field to the struct (after `cdn_bucket`):
|
|
|
|
```rust
|
|
/// Bucket name used for CDN object storage.
|
|
pub cdn_bucket: String,
|
|
/// Dev-only: when true, all auth/permission checks are bypassed and every
|
|
/// request is treated as a super-admin. NEVER enable in production.
|
|
pub disable_auth: bool,
|
|
}
|
|
```
|
|
|
|
And in `from_env()`, add after the `cdn_bucket` line:
|
|
|
|
```rust
|
|
cdn_bucket: std::env::var("CDN_BUCKET").unwrap_or_else(|_| "mercury".into()),
|
|
disable_auth: std::env::var("DISABLE_AUTH")
|
|
.map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true"))
|
|
.unwrap_or(false),
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `cargo test --lib config::tests`
|
|
Expected: PASS (5 tests: the existing `test_config_defaults` plus the 3 new ones)
|
|
|
|
---
|
|
|
|
### Task 2: Middleware bypass
|
|
|
|
**Files:**
|
|
- Modify: `src/auth/mod.rs` (add `bypass_claims`, ~after `permissions` mod, before `Claims` struct)
|
|
- Modify: `src/auth/middleware.rs` (add `authenticate_or_bypass`; rewrite `require_auth`, `require_super_admin`, `require_admin_query`, `require_admin_cache`, `blacklist_layer`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `Config.disable_auth: bool` (Task 1), `AppState { pool, query_cache, blacklist_cache, cors_cache, http_client, cdn_base_url, config }` (`src/state.rs`).
|
|
- Produces: `pub fn bypass_claims() -> Claims` (`src/auth/mod.rs`), `async fn authenticate_or_bypass(state: &AppState, req: &mut Request) -> Option<Claims>` (`src/auth/middleware.rs`, crate-private) — used by all `require_*` functions and `blacklist_layer`.
|
|
|
|
- [ ] **Step 1: Write the failing test for `bypass_claims`**
|
|
|
|
Add to the `tests` mod at the bottom of `src/auth/mod.rs`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn test_bypass_claims_has_full_permissions() {
|
|
let claims = bypass_claims();
|
|
assert_eq!(claims.sub, "dev-bypass");
|
|
assert_eq!(claims.permissions_mask(), u128::MAX);
|
|
assert!(claims.has_permission(permissions::SUPER_ADMIN));
|
|
assert!(claims.has_permission(permissions::ADMIN_QUERY));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `cargo test --lib auth::tests::test_bypass_claims_has_full_permissions`
|
|
Expected: FAIL — `cannot find function \`bypass_claims\`` (compile error)
|
|
|
|
- [ ] **Step 3: Implement `bypass_claims`**
|
|
|
|
In `src/auth/mod.rs`, add after the `permissions` mod block (before `Claims` struct):
|
|
|
|
```rust
|
|
/// Synthetic super-admin claims used when `DISABLE_AUTH` is set. Grants every
|
|
/// permission bit so all `require_*` and blacklist bypass checks pass.
|
|
pub fn bypass_claims() -> Claims {
|
|
Claims {
|
|
sub: "dev-bypass".to_string(),
|
|
permissions: u128::MAX.to_string(),
|
|
exp: usize::MAX,
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `cargo test --lib auth::tests::test_bypass_claims_has_full_permissions`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Write the failing tests for `authenticate_or_bypass`**
|
|
|
|
Add a `tests` mod at the bottom of `src/auth/middleware.rs`:
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::state::{AppState, BlacklistCache, CorsCache, QueryCache};
|
|
use axum::body::Body;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use std::sync::Arc;
|
|
|
|
fn test_state(disable_auth: bool) -> AppState {
|
|
// connect_lazy performs no I/O — safe to use without a running DB.
|
|
let pool = PgPoolOptions::new()
|
|
.connect_lazy("postgres://user:pass@localhost/db")
|
|
.expect("lazy pool");
|
|
AppState {
|
|
pool,
|
|
query_cache: QueryCache::new(),
|
|
blacklist_cache: BlacklistCache::new(),
|
|
cors_cache: CorsCache::new(),
|
|
http_client: reqwest::Client::new(),
|
|
cdn_base_url: "http://localhost:9000/mercury".into(),
|
|
config: Arc::new(crate::config::Config {
|
|
database_url: "postgres://user:pass@localhost/db".into(),
|
|
jwt_secret: "secret".into(),
|
|
jwt_expiry_secs: 3600,
|
|
cache_max_capacity: 10000,
|
|
cache_idle_timeout_secs: 300,
|
|
cache_sweep_interval_secs: 60,
|
|
cors_origins: vec![],
|
|
cdn_endpoint: "http://localhost:9000".into(),
|
|
cdn_bucket: "mercury".into(),
|
|
disable_auth,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn test_request() -> Request {
|
|
Request::builder()
|
|
.uri("/api/orders")
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bypass_when_disabled_and_no_token() {
|
|
let state = test_state(true);
|
|
let mut req = test_request();
|
|
let claims = authenticate_or_bypass(&state, &mut req).await;
|
|
assert_eq!(claims.map(|c| c.permissions_mask()), Some(u128::MAX));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_no_bypass_when_enabled_and_no_token() {
|
|
let state = test_state(false);
|
|
let mut req = test_request();
|
|
let claims = authenticate_or_bypass(&state, &mut req).await;
|
|
assert!(claims.is_none());
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run tests to verify they fail**
|
|
|
|
Run: `cargo test --lib auth::middleware::tests`
|
|
Expected: FAIL — `cannot find function \`authenticate_or_bypass\`` (compile error)
|
|
|
|
- [ ] **Step 7: Implement `authenticate_or_bypass` and rewire the `require_*` functions and `blacklist_layer`**
|
|
|
|
In `src/auth/middleware.rs`, add this function directly after the existing `authenticate` function:
|
|
|
|
```rust
|
|
/// Like `authenticate`, but short-circuits to a synthetic super-admin when
|
|
/// `DISABLE_AUTH` is set — no `Authorization` header required in that case.
|
|
async fn authenticate_or_bypass(state: &AppState, req: &mut Request) -> Option<Claims> {
|
|
if state.config.disable_auth {
|
|
let claims = crate::auth::bypass_claims();
|
|
req.extensions_mut().insert(claims.clone());
|
|
return Some(claims);
|
|
}
|
|
let token = extract_bearer(req)?;
|
|
authenticate(&token, state, req).await
|
|
}
|
|
```
|
|
|
|
Replace the body of `blacklist_layer`'s `caller_mask` computation:
|
|
|
|
```rust
|
|
let caller_mask = if let Some(token) = extract_bearer(&req) {
|
|
authenticate(&token, &state, &mut req)
|
|
.await
|
|
.map(|c| c.permissions_mask())
|
|
.unwrap_or(0)
|
|
} else {
|
|
0
|
|
};
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
let caller_mask = authenticate_or_bypass(&state, &mut req)
|
|
.await
|
|
.map(|c| c.permissions_mask())
|
|
.unwrap_or(0);
|
|
```
|
|
|
|
Replace `require_auth`'s body:
|
|
|
|
```rust
|
|
pub async fn require_auth(
|
|
State(state): State<AppState>,
|
|
mut req: Request,
|
|
next: Next,
|
|
) -> Result<Response, StatusCode> {
|
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
|
authenticate(&token, &state, &mut req)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
Ok(next.run(req).await)
|
|
}
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
pub async fn require_auth(
|
|
State(state): State<AppState>,
|
|
mut req: Request,
|
|
next: Next,
|
|
) -> Result<Response, StatusCode> {
|
|
authenticate_or_bypass(&state, &mut req)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
Ok(next.run(req).await)
|
|
}
|
|
```
|
|
|
|
For each of `require_super_admin`, `require_admin_query`, `require_admin_cache`, replace:
|
|
|
|
```rust
|
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
|
let claims = authenticate(&token, &state, &mut req)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
let claims = authenticate_or_bypass(&state, &mut req)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
```
|
|
|
|
(leave the rest of each function — the `has_permission` check and `next.run(req).await` — unchanged).
|
|
|
|
- [ ] **Step 8: Run tests to verify they pass**
|
|
|
|
Run: `cargo test --lib auth::`
|
|
Expected: PASS (all `auth::mod` and `auth::middleware` tests, including the 2 new ones)
|
|
|
|
- [ ] **Step 9: Run the full test suite to check for regressions**
|
|
|
|
Run: `cargo test --lib`
|
|
Expected: PASS — all existing tests (`state::tests`, `routes::crud::tests`, `routes::admin::*::tests`) still pass unchanged.
|
|
|
|
---
|
|
|
|
### Task 3: `GET /auth/config` endpoint
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/auth.rs` (add `auth_config` handler)
|
|
- Modify: `src/main.rs` (import + route registration, ~line 25 imports, ~line 149 router)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `Config.disable_auth: bool` (Task 1).
|
|
- Produces: `GET /auth/config` → `200 { "disable_auth": bool }`, unauthenticated — consumed by Task 4's frontend auth store.
|
|
|
|
- [ ] **Step 1: Implement the handler**
|
|
|
|
In `src/routes/auth.rs`, add after `login`:
|
|
|
|
```rust
|
|
pub async fn auth_config(State(state): State<AppState>) -> Json<Value> {
|
|
Json(json!({ "disable_auth": state.config.disable_auth }))
|
|
}
|
|
```
|
|
|
|
(No new imports needed — `State`, `Json`, `json`, `Value`, and `AppState` are already imported in this file.)
|
|
|
|
- [ ] **Step 2: Wire the route in `src/main.rs`**
|
|
|
|
Change the import:
|
|
|
|
```rust
|
|
routes::{
|
|
admin::admin_router,
|
|
auth::login,
|
|
```
|
|
|
|
to:
|
|
|
|
```rust
|
|
routes::{
|
|
admin::admin_router,
|
|
auth::{auth_config, login},
|
|
```
|
|
|
|
Change the router construction:
|
|
|
|
```rust
|
|
let app = Router::new()
|
|
.route("/auth/login", post(login))
|
|
```
|
|
|
|
to:
|
|
|
|
```rust
|
|
let app = Router::new()
|
|
.route("/auth/login", post(login))
|
|
.route("/auth/config", get(auth_config))
|
|
```
|
|
|
|
- [ ] **Step 3: Verify it builds**
|
|
|
|
Run: `cargo build`
|
|
Expected: builds with no errors.
|
|
|
|
- [ ] **Step 4: Manual verification (no DB-backed router tests exist in this codebase — see Global Constraints)**
|
|
|
|
Run: `docker compose up --build -d db cdn cdn-init api` (or `./dev.sh` for the full stack), then:
|
|
|
|
```bash
|
|
curl -s http://localhost:3000/auth/config
|
|
```
|
|
|
|
Expected with `DISABLE_AUTH` unset: `{"disable_auth":false}`
|
|
Then set `DISABLE_AUTH=true` in the environment (or `.env`), restart the `api` service, and re-run the curl — expected: `{"disable_auth":true}`. Also verify `curl -s http://localhost:3000/api/orders` (or any CRUD table) now returns `200` with no `Authorization` header, instead of `401`.
|
|
|
|
---
|
|
|
|
### Task 4: Frontend — skip login when auth is disabled
|
|
|
|
**Files:**
|
|
- Modify: `ui/src/stores/auth.ts` (full content shown below)
|
|
- Modify: `ui/src/router/index.ts` (full content shown below)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GET /auth/config` (Task 3).
|
|
- Produces: `useAuthStore().checkAuthDisabled(): Promise<void>`, `useAuthStore().authDisabled: Ref<boolean>` — consumed by the router guard.
|
|
|
|
- [ ] **Step 1: Update the auth store**
|
|
|
|
Replace the full contents of `ui/src/stores/auth.ts` with:
|
|
|
|
```ts
|
|
import { defineStore } from "pinia";
|
|
import { ref, computed } from "vue";
|
|
|
|
interface Claims {
|
|
sub: string;
|
|
permissions: string;
|
|
exp: number;
|
|
}
|
|
|
|
function parseJwt(token: string): Claims | null {
|
|
try {
|
|
const payload = token.split(".")[1];
|
|
return payload ? (JSON.parse(atob(payload)) as Claims) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const useAuthStore = defineStore("auth", () => {
|
|
const token = ref<string | null>(localStorage.getItem("mercury_token"));
|
|
const claims = computed<Claims | null>(() =>
|
|
token.value ? parseJwt(token.value) : null,
|
|
);
|
|
|
|
// DISABLE_AUTH dev mode: fetched once from the backend at boot.
|
|
const authDisabled = ref(false);
|
|
let configCheckPromise: Promise<void> | null = null;
|
|
|
|
function checkAuthDisabled(): Promise<void> {
|
|
if (!configCheckPromise) {
|
|
configCheckPromise = fetch("/auth/config")
|
|
.then((res) => (res.ok ? res.json() : { disable_auth: false }))
|
|
.then((data) => {
|
|
authDisabled.value = Boolean(data.disable_auth);
|
|
})
|
|
.catch(() => {
|
|
authDisabled.value = false;
|
|
});
|
|
}
|
|
return configCheckPromise;
|
|
}
|
|
|
|
const isAuthenticated = computed(() => {
|
|
if (authDisabled.value) return true;
|
|
if (!claims.value) return false;
|
|
return claims.value.exp * 1000 > Date.now();
|
|
});
|
|
const username = computed(() =>
|
|
authDisabled.value ? "dev-bypass" : (claims.value?.sub ?? ""),
|
|
);
|
|
|
|
function hasPermission(bit: bigint): boolean {
|
|
if (authDisabled.value) return true;
|
|
if (!claims.value) return false;
|
|
const mask = BigInt(claims.value.permissions);
|
|
return (mask & bit) !== 0n;
|
|
}
|
|
|
|
const isSuperAdmin = computed(() => authDisabled.value || hasPermission(32n));
|
|
|
|
async function login(username: string, password: string): Promise<void> {
|
|
const res = await fetch("/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
if (!res.ok) throw new Error("Invalid credentials");
|
|
const data = await res.json();
|
|
token.value = data.token;
|
|
localStorage.setItem("mercury_token", data.token);
|
|
}
|
|
|
|
function logout() {
|
|
token.value = null;
|
|
localStorage.removeItem("mercury_token");
|
|
}
|
|
|
|
function authHeaders(): Record<string, string> {
|
|
return token.value ? { Authorization: `Bearer ${token.value}` } : {};
|
|
}
|
|
|
|
return {
|
|
token,
|
|
claims,
|
|
authDisabled,
|
|
checkAuthDisabled,
|
|
isAuthenticated,
|
|
username,
|
|
isSuperAdmin,
|
|
hasPermission,
|
|
login,
|
|
logout,
|
|
authHeaders,
|
|
};
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Update the router guard**
|
|
|
|
Replace the full contents of `ui/src/router/index.ts` with:
|
|
|
|
```ts
|
|
import { createRouter, createWebHistory } from 'vue-router'
|
|
import { useAuthStore } from '../stores/auth'
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(),
|
|
routes: [
|
|
{ path: '/login', component: () => import('../views/Login.vue') },
|
|
{
|
|
path: '/admin',
|
|
component: () => import('../views/admin/Layout.vue'),
|
|
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') },
|
|
],
|
|
meta: { requiresAuth: true },
|
|
},
|
|
{ path: '/', redirect: '/admin/queries' },
|
|
],
|
|
})
|
|
|
|
router.beforeEach(async (to) => {
|
|
const auth = useAuthStore()
|
|
await auth.checkAuthDisabled()
|
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
|
return '/login'
|
|
}
|
|
if (to.path === '/login' && auth.isAuthenticated) {
|
|
return '/admin/queries'
|
|
}
|
|
})
|
|
|
|
export default router
|
|
```
|
|
|
|
- [ ] **Step 3: Verify the FE builds**
|
|
|
|
Run: `cd ui && npm run build`
|
|
Expected: builds with no TypeScript errors.
|
|
|
|
- [ ] **Step 4: Manual browser verification (no FE test framework configured in this project)**
|
|
|
|
With `DISABLE_AUTH` unset, run `./dev.sh`, open `http://localhost:3000` — expect the normal `/login` redirect.
|
|
With `DISABLE_AUTH=true` set (e.g. `DISABLE_AUTH=true docker compose up --build`), open `http://localhost:3000` — expect immediate landing on `/admin/queries` with no login prompt, and that every admin nav section (Users, Permissions, Blacklist, API Keys, Cache, CORS, CDN) loads data without a 401.
|
|
|
|
---
|
|
|
|
### Task 5: Docs and compose plumbing
|
|
|
|
**Files:**
|
|
- Modify: `.env.example`
|
|
- Modify: `docker-compose.yml` (`api` service `environment` block, ~line 40)
|
|
- Modify: `README.md` (Configuration table, ~line 146-153)
|
|
|
|
**Interfaces:**
|
|
- Consumes: nothing (documentation-only).
|
|
- Produces: nothing consumed by other tasks — this is the last task.
|
|
|
|
- [ ] **Step 1: Update `.env.example`**
|
|
|
|
Add after `CORS_ORIGINS=`:
|
|
|
|
```
|
|
# Comma-separated allowed CORS origins, or * for permissive. Empty = no CORS headers.
|
|
CORS_ORIGINS=
|
|
|
|
# DEV ONLY: set to "true" to bypass all authentication/permission checks
|
|
# (every request is treated as a super-admin, no login required). Never set
|
|
# this in production.
|
|
DISABLE_AUTH=
|
|
```
|
|
|
|
- [ ] **Step 2: Update `docker-compose.yml`**
|
|
|
|
In the `api` service `environment` block, add after `CDN_BUCKET: mercury`:
|
|
|
|
```yaml
|
|
CDN_BUCKET: mercury
|
|
DISABLE_AUTH: ${DISABLE_AUTH:-false}
|
|
```
|
|
|
|
- [ ] **Step 3: Update `README.md`**
|
|
|
|
In the Configuration table, add a row after `CACHE_SWEEP_INTERVAL_SECS`:
|
|
|
|
```markdown
|
|
| `CACHE_SWEEP_INTERVAL_SECS` | 60 | Sweep interval for eviction task |
|
|
| `DISABLE_AUTH` | `false` | **Dev only.** When `true`, bypasses all authentication/permission checks — every request is treated as a super-admin and the admin UI skips login. Never enable in production. |
|
|
```
|
|
|
|
- [ ] **Step 4: Verify docs render sanely**
|
|
|
|
Run: `git diff --stat .env.example docker-compose.yml README.md`
|
|
Expected: shows the 3 files with small additive diffs, no unrelated changes.
|
|
|
|
---
|
|
|
|
## Final Verification
|
|
|
|
- [ ] `cargo test --lib` — all tests pass.
|
|
- [ ] `cargo build` — builds clean.
|
|
- [ ] `cd ui && npm run build` — builds clean.
|
|
- [ ] Manual: `DISABLE_AUTH=true docker compose up --build`, confirm UI skips login and all admin sections load; then unset it and confirm `/login` is required again (existing `admin`/`admin`-style credentials per README, or `MERCURY_ADMIN_USER`/`MERCURY_ADMIN_PASSWORD` from compose).
|