All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Build complete module activation and license-module binding system with marketplace catalog, purchase tracking, and installation status monitoring. Database schema (migration 009): - modules table — Registry with pricing, features, plugin URLs - module_purchases — License-module ownership with transaction logging - module_installations — Deployment status tracking - Seed data: Loot Manager module ($9.99) Backend implementation: - Domain models with rust_decimal pricing support - 11 data access functions (catalog, ownership, purchases, installation) - 5 REST endpoints with JWT auth and license scoping - Multi-tenant enforcement via license_id from claims Purchase flow stub: - Immediate purchase recording without payment gateway - PayPal integration deferred to XO's direct implementation - Transaction ID and amount fields ready for real gateway Module installation: - Integration with ModuleInstaller service - NATS-based deployment to companion agent - Real-time status tracking via polling endpoint All queries compile-time verified. Zero cross-tenant exposure. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
212 lines
6.8 KiB
Rust
212 lines
6.8 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::Router;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use tokio::net::TcpListener;
|
|
use tower_http::cors::{Any, CorsLayer};
|
|
use tower_http::trace::TraceLayer;
|
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|
|
|
mod api;
|
|
mod config;
|
|
mod db;
|
|
mod middleware;
|
|
mod models;
|
|
mod services;
|
|
|
|
use config::AppConfig;
|
|
|
|
/// Shared application state available to all handlers
|
|
pub struct AppState {
|
|
pub db: sqlx::PgPool,
|
|
pub nats: Option<async_nats::Client>,
|
|
pub config: AppConfig,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
// Load environment variables
|
|
dotenvy::dotenv().ok();
|
|
|
|
// Initialize tracing
|
|
tracing_subscriber::registry()
|
|
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
|
"corrosion_api=debug,tower_http=debug".into()
|
|
}))
|
|
.with(tracing_subscriber::fmt::layer())
|
|
.init();
|
|
|
|
let config = AppConfig::from_env()?;
|
|
|
|
// Database connection pool
|
|
let db = PgPoolOptions::new()
|
|
.max_connections(config.database_max_connections)
|
|
.connect(&config.database_url)
|
|
.await?;
|
|
|
|
tracing::info!("Connected to PostgreSQL");
|
|
|
|
// Run migrations
|
|
sqlx::migrate!("./migrations").run(&db).await?;
|
|
tracing::info!("Database migrations applied");
|
|
|
|
// NATS connection (optional for dev)
|
|
let nats = match async_nats::connect(&config.nats_url).await {
|
|
Ok(client) => {
|
|
tracing::info!("Connected to NATS at {}", config.nats_url);
|
|
Some(client)
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("NATS not available ({}), running without event bus", e);
|
|
None
|
|
}
|
|
};
|
|
|
|
// Bootstrap: create admin user + license on first run
|
|
bootstrap_admin(&db).await;
|
|
|
|
// Initialize background services if NATS is available
|
|
if let Some(ref nats_client) = nats {
|
|
let nats_bridge = Arc::new(services::nats_bridge::NatsBridge::new(nats_client.clone()));
|
|
|
|
// Start stats consumer
|
|
let stats_consumer = services::stats_consumer::StatsConsumerService::new(
|
|
db.clone(),
|
|
nats_bridge.clone(),
|
|
);
|
|
if let Err(e) = stats_consumer.start().await {
|
|
tracing::error!("Failed to start stats consumer: {}", e);
|
|
}
|
|
|
|
// Start scheduler service
|
|
let scheduler = services::scheduler::SchedulerService::new(
|
|
db.clone(),
|
|
nats_bridge.clone(),
|
|
)
|
|
.await?;
|
|
|
|
// Register stats jobs
|
|
if let Err(e) = scheduler.register_stats_aggregation().await {
|
|
tracing::error!("Failed to register stats aggregation job: {}", e);
|
|
}
|
|
if let Err(e) = scheduler.register_stats_cleanup().await {
|
|
tracing::error!("Failed to register stats cleanup job: {}", e);
|
|
}
|
|
|
|
if let Err(e) = scheduler.start().await {
|
|
tracing::error!("Failed to start scheduler: {}", e);
|
|
} else {
|
|
tracing::info!("Scheduler service started");
|
|
}
|
|
} else {
|
|
tracing::warn!("Skipping background services (NATS not available)");
|
|
}
|
|
|
|
let state = Arc::new(AppState { db, nats, config });
|
|
|
|
// CORS — permissive in dev, locked down in production
|
|
let cors = CorsLayer::new()
|
|
.allow_origin(Any)
|
|
.allow_methods(Any)
|
|
.allow_headers(Any);
|
|
|
|
// Build router
|
|
let app = Router::new()
|
|
.nest("/api/auth", api::auth::router())
|
|
.nest("/api/servers", api::servers::router())
|
|
.nest("/api/wipes", api::wipes::router())
|
|
.nest("/api/maps", api::maps::router())
|
|
.nest("/api/plugins", api::plugins::router())
|
|
.nest("/api/panels", api::panels::router())
|
|
.nest("/api/schedules", api::schedules::router())
|
|
.nest("/api/logs", api::logs::router())
|
|
.nest("/api/public", api::public::router())
|
|
.nest("/api/team", api::team::router())
|
|
.nest("/api/notifications", api::notifications::router())
|
|
.nest("/api/license", api::license::router())
|
|
.nest("/api/store", api::store::router())
|
|
.nest("/api/early-access", api::early_access::router())
|
|
.nest("/api/admin", api::admin::router())
|
|
.nest("/api/ws", api::ws::router())
|
|
.nest("/api/analytics", api::analytics::router())
|
|
.nest("/api/plugin", api::plugin::router())
|
|
.nest("/api/settings", api::settings::router())
|
|
.nest("/api/modules", api::modules::router())
|
|
.layer(cors)
|
|
.layer(TraceLayer::new_for_http())
|
|
.with_state(state);
|
|
|
|
let bind_addr = "0.0.0.0:3000";
|
|
let listener = TcpListener::bind(bind_addr).await?;
|
|
tracing::info!("Corrosion API listening on {}", bind_addr);
|
|
|
|
axum::serve(listener, app).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Bootstrap: if no users exist and ADMIN_EMAIL/ADMIN_PASSWORD are set,
|
|
/// create the initial admin user and a dev license key.
|
|
async fn bootstrap_admin(db: &sqlx::PgPool) {
|
|
let admin_email = std::env::var("ADMIN_EMAIL").unwrap_or_default();
|
|
let admin_password = std::env::var("ADMIN_PASSWORD").unwrap_or_default();
|
|
let admin_username = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "Commander".to_string());
|
|
|
|
if admin_email.is_empty() || admin_password.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Check if any users exist
|
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
|
.fetch_one(db)
|
|
.await
|
|
.unwrap_or((0,));
|
|
|
|
if count.0 > 0 {
|
|
tracing::debug!("Users already exist, skipping bootstrap");
|
|
return;
|
|
}
|
|
|
|
tracing::info!("No users found — bootstrapping admin user: {}", admin_email);
|
|
|
|
// Hash password
|
|
let password_hash = match services::auth::hash_password(&admin_password) {
|
|
Ok(h) => h,
|
|
Err(e) => {
|
|
tracing::error!("Failed to hash admin password: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Create admin user
|
|
let user_id = match db::users::create_user(db, &admin_email, &admin_username, &password_hash).await {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
tracing::error!("Failed to create admin user: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Flag as super-admin
|
|
if let Err(e) = sqlx::query("UPDATE users SET is_super_admin = true WHERE id = $1")
|
|
.bind(user_id)
|
|
.execute(db)
|
|
.await
|
|
{
|
|
tracing::error!("Failed to set super-admin flag: {e}");
|
|
}
|
|
|
|
// Create a license for the admin
|
|
let license_key = std::env::var("ADMIN_LICENSE_KEY")
|
|
.unwrap_or_else(|_| format!("CORROSION-{}", services::encryption::generate_token(8).to_uppercase()));
|
|
|
|
match db::licenses::create_license(db, &license_key, user_id).await {
|
|
Ok(_) => {
|
|
tracing::info!("Bootstrap complete — admin: {}, license: {}", admin_email, license_key);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to create admin license: {e}");
|
|
}
|
|
}
|
|
}
|