Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ RPC_BATCH_SIZE=20
# API_DB_MAX_CONNECTIONS=20
# SSE_REPLAY_BUFFER_BLOCKS=4096 # replay tail used only for active connected clients

# Branding / white-label (all optional)
# CHAIN_LOGO_URL= # URL or path to logo (e.g., /branding/logo.svg). Default: bundled logo
# ACCENT_COLOR= # Primary accent hex (e.g. #3b82f6). Default: #dc2626 (red)
# BACKGROUND_COLOR_DARK= # Dark mode base background hex. Default: #050505
# BACKGROUND_COLOR_LIGHT= # Light mode base background hex. Default: #f4ede6
# SUCCESS_COLOR= # Success indicator hex. Default: #22c55e
# ERROR_COLOR= # Error indicator hex. Default: #dc2626

# Optional faucet feature
# FAUCET_ENABLED=false
# FAUCET_PRIVATE_KEY=0x...
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ logs/
frontend/node_modules/
frontend/dist/
frontend/.env.local

# Branding assets (track directory, not contents)
branding/*
!branding/.gitkeep
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,13 @@ Copy `.env.example` to `.env` and set `RPC_URL`. Common options:
| `IPFS_GATEWAY` | Gateway for NFT metadata | `https://ipfs.io/ipfs/` |
| `REINDEX` | Wipe and reindex from start | `false` |

See [White Labeling](docs/WHITE_LABELING.md) for branding customization (chain name, logo, colors).

## Documentation

- [API Reference](docs/API.md)
- [Architecture](docs/ARCHITECTURE.md)
- [White Labeling](docs/WHITE_LABELING.md)
- [Product Requirements](docs/PRD.md)

## License
Expand Down
84 changes: 84 additions & 0 deletions backend/crates/atlas-server/src/api/handlers/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use axum::{extract::State, Json};
use serde::Serialize;
use std::sync::Arc;

use crate::api::AppState;

#[derive(Serialize)]
pub struct BrandingConfig {
pub chain_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub accent_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_color_dark: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_color_light: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub success_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_color: Option<String>,
}

/// GET /api/config - Returns white-label branding configuration
/// No DB access, no auth — returns static config from environment variables
pub async fn get_config(State(state): State<Arc<AppState>>) -> Json<BrandingConfig> {
Json(BrandingConfig {
chain_name: state.chain_name.clone(),
logo_url: state.chain_logo_url.clone(),
accent_color: state.accent_color.clone(),
background_color_dark: state.background_color_dark.clone(),
background_color_light: state.background_color_light.clone(),
success_color: state.success_color.clone(),
error_color: state.error_color.clone(),
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn branding_config_skips_none_fields() {
let config = BrandingConfig {
chain_name: "TestChain".to_string(),
logo_url: None,
accent_color: Some("#3b82f6".to_string()),
background_color_dark: None,
background_color_light: None,
success_color: None,
error_color: None,
};

let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["chain_name"], "TestChain");
assert_eq!(json["accent_color"], "#3b82f6");
assert!(json.get("logo_url").is_none());
assert!(json.get("background_color_dark").is_none());
assert!(json.get("success_color").is_none());
assert!(json.get("error_color").is_none());
}

#[test]
fn branding_config_includes_all_fields_when_set() {
let config = BrandingConfig {
chain_name: "MyChain".to_string(),
logo_url: Some("/branding/logo.svg".to_string()),
accent_color: Some("#3b82f6".to_string()),
background_color_dark: Some("#0a0a0f".to_string()),
background_color_light: Some("#faf5ef".to_string()),
success_color: Some("#10b981".to_string()),
error_color: Some("#ef4444".to_string()),
};

let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["chain_name"], "MyChain");
assert_eq!(json["logo_url"], "/branding/logo.svg");
assert_eq!(json["accent_color"], "#3b82f6");
assert_eq!(json["background_color_dark"], "#0a0a0f");
assert_eq!(json["background_color_light"], "#faf5ef");
assert_eq!(json["success_color"], "#10b981");
assert_eq!(json["error_color"], "#ef4444");
}
}
6 changes: 6 additions & 0 deletions backend/crates/atlas-server/src/api/handlers/faucet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ mod tests {
faucet,
chain_id: 1,
chain_name: "Test Chain".to_string(),
chain_logo_url: None,
accent_color: None,
background_color_dark: None,
background_color_light: None,
success_color: None,
error_color: None,
})
}

Expand Down
1 change: 1 addition & 0 deletions backend/crates/atlas-server/src/api/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod addresses;
pub mod blocks;
pub mod config;
pub mod etherscan;
pub mod faucet;
pub mod logs;
Expand Down
6 changes: 6 additions & 0 deletions backend/crates/atlas-server/src/api/handlers/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ mod tests {
faucet: None,
chain_id: 1,
chain_name: "Test Chain".to_string(),
chain_logo_url: None,
accent_color: None,
background_color_dark: None,
background_color_light: None,
success_color: None,
error_color: None,
}))
}

Expand Down
14 changes: 14 additions & 0 deletions backend/crates/atlas-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ pub struct AppState {
pub faucet: Option<SharedFaucetBackend>,
pub chain_id: u64,
pub chain_name: String,
pub chain_logo_url: Option<String>,
pub accent_color: Option<String>,
pub background_color_dark: Option<String>,
pub background_color_light: Option<String>,
pub success_color: Option<String>,
pub error_color: Option<String>,
}

/// Build the Axum router.
Expand Down Expand Up @@ -145,6 +151,8 @@ pub fn build_router(state: Arc<AppState>, cors_origin: Option<String>) -> Router
// Status
.route("/api/height", get(handlers::status::get_height))
.route("/api/status", get(handlers::status::get_status))
// Config (white-label branding)
.route("/api/config", get(handlers::config::get_config))
// Health
.route("/health", get(|| async { "OK" }));

Expand Down Expand Up @@ -244,6 +252,12 @@ mod tests {
faucet,
chain_id: 1,
chain_name: "Test Chain".to_string(),
chain_logo_url: None,
accent_color: None,
background_color_dark: None,
background_color_light: None,
success_color: None,
error_color: None,
})
}

Expand Down
91 changes: 90 additions & 1 deletion backend/crates/atlas-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ pub struct Config {
pub cors_origin: Option<String>,
pub sse_replay_buffer_blocks: usize,
pub chain_name: String,

// Branding / white-label
pub chain_logo_url: Option<String>,
pub accent_color: Option<String>,
pub background_color_dark: Option<String>,
pub background_color_light: Option<String>,
pub success_color: Option<String>,
pub error_color: Option<String>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -123,7 +131,17 @@ impl Config {
.context("Invalid API_PORT")?,
cors_origin: env::var("CORS_ORIGIN").ok(),
sse_replay_buffer_blocks,
chain_name: env::var("CHAIN_NAME").unwrap_or_else(|_| "Unknown".to_string()),
chain_name: env::var("CHAIN_NAME")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Unknown".to_string()),
chain_logo_url: parse_optional_env(env::var("CHAIN_LOGO_URL").ok()),
accent_color: parse_optional_env(env::var("ACCENT_COLOR").ok()),
background_color_dark: parse_optional_env(env::var("BACKGROUND_COLOR_DARK").ok()),
background_color_light: parse_optional_env(env::var("BACKGROUND_COLOR_LIGHT").ok()),
success_color: parse_optional_env(env::var("SUCCESS_COLOR").ok()),
error_color: parse_optional_env(env::var("ERROR_COLOR").ok()),
})
}
}
Expand Down Expand Up @@ -175,6 +193,10 @@ impl FaucetConfig {
}
}

fn parse_optional_env(val: Option<String>) -> Option<String> {
val.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}

fn parse_faucet_amount_to_wei(amount: &str) -> Result<U256> {
let trimmed = amount.trim();
if trimmed.is_empty() {
Expand Down Expand Up @@ -240,6 +262,73 @@ mod tests {
env::set_var("FAUCET_COOLDOWN_MINUTES", "30");
}

#[test]
fn chain_name_defaults_to_unknown_when_unset() {
let _lock = ENV_LOCK.lock().unwrap();
set_required_env();
env::remove_var("CHAIN_NAME");
assert_eq!(Config::from_env().unwrap().chain_name, "Unknown");
}

#[test]
fn chain_name_defaults_to_unknown_when_empty() {
let _lock = ENV_LOCK.lock().unwrap();
set_required_env();
env::set_var("CHAIN_NAME", "");
assert_eq!(Config::from_env().unwrap().chain_name, "Unknown");
env::remove_var("CHAIN_NAME");
}

#[test]
fn chain_name_defaults_to_unknown_when_whitespace_only() {
let _lock = ENV_LOCK.lock().unwrap();
set_required_env();
env::set_var("CHAIN_NAME", " ");
assert_eq!(Config::from_env().unwrap().chain_name, "Unknown");
env::remove_var("CHAIN_NAME");
}

#[test]
fn chain_name_uses_provided_value() {
let _lock = ENV_LOCK.lock().unwrap();
set_required_env();
env::set_var("CHAIN_NAME", "MyChain");
assert_eq!(Config::from_env().unwrap().chain_name, "MyChain");
env::remove_var("CHAIN_NAME");
}

#[test]
fn chain_name_trims_surrounding_whitespace() {
let _lock = ENV_LOCK.lock().unwrap();
set_required_env();
env::set_var("CHAIN_NAME", " MyChain ");
assert_eq!(Config::from_env().unwrap().chain_name, "MyChain");
env::remove_var("CHAIN_NAME");
}

#[test]
fn optional_env_returns_none_when_unset() {
assert_eq!(parse_optional_env(None), None);
}

#[test]
fn optional_env_returns_none_when_empty() {
assert_eq!(parse_optional_env(Some("".to_string())), None);
}

#[test]
fn optional_env_returns_none_when_whitespace_only() {
assert_eq!(parse_optional_env(Some(" ".to_string())), None);
}

#[test]
fn optional_env_trims_and_returns_value() {
assert_eq!(
parse_optional_env(Some(" #dc2626 ".to_string())),
Some("#dc2626".to_string())
);
}

#[test]
fn sse_replay_buffer_validation() {
let _lock = ENV_LOCK.lock().unwrap();
Expand Down
6 changes: 6 additions & 0 deletions backend/crates/atlas-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ async fn main() -> Result<()> {
faucet,
chain_id,
chain_name: config.chain_name.clone(),
chain_logo_url: config.chain_logo_url.clone(),
accent_color: config.accent_color.clone(),
background_color_dark: config.background_color_dark.clone(),
background_color_light: config.background_color_light.clone(),
success_color: config.success_color.clone(),
error_color: config.error_color.clone(),
});

// Spawn indexer task with retry logic
Expand Down
Empty file added branding/.gitkeep
Empty file.
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ services:
FAUCET_AMOUNT: ${FAUCET_AMOUNT:-}
FAUCET_COOLDOWN_MINUTES: ${FAUCET_COOLDOWN_MINUTES:-}
CHAIN_NAME: ${CHAIN_NAME:-Unknown}
CHAIN_LOGO_URL: ${CHAIN_LOGO_URL:-}
ACCENT_COLOR: ${ACCENT_COLOR:-}
BACKGROUND_COLOR_DARK: ${BACKGROUND_COLOR_DARK:-}
BACKGROUND_COLOR_LIGHT: ${BACKGROUND_COLOR_LIGHT:-}
SUCCESS_COLOR: ${SUCCESS_COLOR:-}
ERROR_COLOR: ${ERROR_COLOR:-}
API_HOST: 0.0.0.0
API_PORT: 3000
RUST_LOG: atlas_server=info,tower_http=info
Expand All @@ -47,6 +53,8 @@ services:
dockerfile: Dockerfile
ports:
- "80:8080"
volumes:
- ${BRANDING_DIR:-./branding}:/usr/share/nginx/html/branding:ro
depends_on:
- atlas-server
restart: unless-stopped
Expand Down
Loading
Loading