feat: Implement Phase 6 B2B hosting integration (minimal viable B2B)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Backend infrastructure for hosting provider reseller program (Model B). Database Schema (Migration 012): - hosts table: Hosting company accounts with API key authentication - host_licenses: Tracks licenses provisioned by each host - host_billing_records: Monthly billing data ($6/server wholesale) Host Provisioning Service: - API key authentication (SHA-256 hashed, bearer token) - Bulk license provisioning (single call creates user + license + associations) - Auto-generation: license keys, companion tokens, subdomain slugs - Active license counting for billing - Monthly billing record generation with CSV export support Host API Endpoints: - POST /api/host/provision: Bulk license creation * Input: server_id, hostname, customer_email * Output: license_key, companion_token, plugin_download_url, subdomain, panel_url - GET /api/host/licenses: List all host-provisioned licenses with status - GET /api/host/billing/:month: Monthly billing report (YYYY-MM format) Security: - Separate authentication system (API keys vs user JWTs) - Host-level query isolation (all operations scoped by host_id) - SHA-256 API key hashing - CORS protection on host endpoints Business Model: - $6/server/month wholesale rate (configurable per host) - Manual invoicing (no Stripe integration in MVP) - Hosts control their own markup to end customers Per B2B_RESELLER_PLAN.md: Minimal viable B2B implementation (Model B). No white-label branding, SSO, or complex integration required. Simple API-based provisioning for hosting partners. Production ready for initial hosting partner testing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
256
backend/src/api/host.rs
Normal file
256
backend/src/api/host.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::error::AppError,
|
||||
services::host_provisioning::{HostProvisioningService, ProvisionedLicense},
|
||||
AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/provision", post(provision_license))
|
||||
.route("/licenses", get(list_host_licenses))
|
||||
.route("/billing/:month", get(get_billing_report))
|
||||
.layer(middleware::from_fn(host_auth_middleware))
|
||||
}
|
||||
|
||||
/// Host API key authentication middleware
|
||||
async fn host_auth_middleware(
|
||||
State(state): State<Arc<AppState>>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
// Extract API key from Authorization header (Bearer token)
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(AppError::Unauthorized("Invalid Authorization format. Use: Bearer <api_key>".to_string()));
|
||||
}
|
||||
|
||||
let api_key = &auth_header[7..]; // Skip "Bearer "
|
||||
|
||||
// Authenticate host
|
||||
let service = HostProvisioningService::new(state.db.clone());
|
||||
let host_id = service
|
||||
.authenticate_host(api_key)
|
||||
.await
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or inactive API key".to_string()))?;
|
||||
|
||||
// Store host_id in request extensions for handlers to access
|
||||
req.extensions_mut().insert(HostContext { host_id });
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Host context extracted from API key
|
||||
#[derive(Clone)]
|
||||
struct HostContext {
|
||||
host_id: Uuid,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BULK LICENSE PROVISIONING
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProvisionRequest {
|
||||
server_id: String, // Host's internal server identifier (e.g., "rust-nyc-01")
|
||||
hostname: Option<String>, // Optional display name
|
||||
customer_email: String, // End customer email
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProvisionResponse {
|
||||
license_key: String,
|
||||
companion_token: String,
|
||||
plugin_download_url: String,
|
||||
subdomain: String,
|
||||
panel_url: String,
|
||||
}
|
||||
|
||||
/// Provision a new license for a hosting customer (B2B bulk provisioning)
|
||||
async fn provision_license(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Extension(ctx): axum::extract::Extension<HostContext>,
|
||||
Json(req): Json<ProvisionRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let service = HostProvisioningService::new(state.db.clone());
|
||||
|
||||
// Use hostname if provided, otherwise use server_id
|
||||
let server_identifier = req.hostname.as_ref().unwrap_or(&req.server_id);
|
||||
|
||||
let provisioned = service
|
||||
.provision_license(ctx.host_id, server_identifier, &req.customer_email)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ProvisionResponse {
|
||||
license_key: provisioned.license_key.clone(),
|
||||
companion_token: provisioned.companion_token,
|
||||
plugin_download_url: provisioned.plugin_download_url,
|
||||
subdomain: provisioned.subdomain.clone(),
|
||||
panel_url: format!("https://panel.corrosionmgmt.com/login?license={}", provisioned.license_key),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST LICENSE MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HostLicenseInfo {
|
||||
license_key: String,
|
||||
server_name: String,
|
||||
customer_email: String,
|
||||
subdomain: String,
|
||||
active: bool,
|
||||
last_seen_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
provisioned_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// List all licenses provisioned by this host
|
||||
async fn list_host_licenses(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Extension(ctx): axum::extract::Extension<HostContext>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let licenses = sqlx::query!(
|
||||
"SELECT
|
||||
l.license_key,
|
||||
l.server_name,
|
||||
l.subdomain,
|
||||
l.active,
|
||||
hl.customer_email,
|
||||
hl.last_seen_at,
|
||||
hl.provisioned_at
|
||||
FROM host_licenses hl
|
||||
INNER JOIN licenses l ON l.id = hl.license_id
|
||||
WHERE hl.host_id = $1
|
||||
ORDER BY hl.provisioned_at DESC",
|
||||
ctx.host_id
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let result: Vec<HostLicenseInfo> = licenses
|
||||
.into_iter()
|
||||
.map(|row| HostLicenseInfo {
|
||||
license_key: row.license_key,
|
||||
server_name: row.server_name.unwrap_or_default(),
|
||||
customer_email: row.customer_email.unwrap_or_default(),
|
||||
subdomain: row.subdomain.unwrap_or_default(),
|
||||
active: row.active,
|
||||
last_seen_at: row.last_seen_at,
|
||||
provisioned_at: row.provisioned_at.unwrap(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BILLING REPORTS
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BillingReport {
|
||||
month: String,
|
||||
active_license_count: i32,
|
||||
wholesale_rate_usd: rust_decimal::Decimal,
|
||||
total_amount_usd: rust_decimal::Decimal,
|
||||
licenses: Vec<BillingLicenseEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BillingLicenseEntry {
|
||||
license_key: String,
|
||||
server_name: String,
|
||||
customer_email: String,
|
||||
active: bool,
|
||||
last_seen_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// Get billing report for a specific month (format: YYYY-MM, e.g., "2026-02")
|
||||
async fn get_billing_report(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Extension(ctx): axum::extract::Extension<HostContext>,
|
||||
Path(month): axum::extract::Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// Parse month (format: YYYY-MM)
|
||||
let billing_month = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%Y-%m-%d")
|
||||
.map_err(|_| AppError::BadRequest("Invalid month format. Use YYYY-MM (e.g., 2026-02)".to_string()))?;
|
||||
|
||||
// Get billing record
|
||||
let record = sqlx::query!(
|
||||
"SELECT active_license_count, wholesale_rate_usd, total_amount_usd
|
||||
FROM host_billing_records
|
||||
WHERE host_id = $1 AND billing_month = $2",
|
||||
ctx.host_id,
|
||||
billing_month
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (active_count, wholesale_rate, total_amount) = if let Some(rec) = record {
|
||||
(
|
||||
rec.active_license_count,
|
||||
rec.wholesale_rate_usd,
|
||||
rec.total_amount_usd,
|
||||
)
|
||||
} else {
|
||||
// No billing record yet — generate on-the-fly
|
||||
let service = HostProvisioningService::new(state.db.clone());
|
||||
let count = service.get_active_license_count(ctx.host_id).await?;
|
||||
let rate = rust_decimal::Decimal::from(6); // Default $6/server
|
||||
let total = rate * rust_decimal::Decimal::from(count);
|
||||
(count as i32, rate, total)
|
||||
};
|
||||
|
||||
// Get license details
|
||||
let licenses = sqlx::query!(
|
||||
"SELECT
|
||||
l.license_key,
|
||||
l.server_name,
|
||||
l.active,
|
||||
hl.customer_email,
|
||||
hl.last_seen_at
|
||||
FROM host_licenses hl
|
||||
INNER JOIN licenses l ON l.id = hl.license_id
|
||||
WHERE hl.host_id = $1
|
||||
ORDER BY l.server_name",
|
||||
ctx.host_id
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let license_entries: Vec<BillingLicenseEntry> = licenses
|
||||
.into_iter()
|
||||
.map(|row| BillingLicenseEntry {
|
||||
license_key: row.license_key,
|
||||
server_name: row.server_name.unwrap_or_default(),
|
||||
customer_email: row.customer_email.unwrap_or_default(),
|
||||
active: row.active,
|
||||
last_seen_at: row.last_seen_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(BillingReport {
|
||||
month: month.clone(),
|
||||
active_license_count: active_count,
|
||||
wholesale_rate_usd: wholesale_rate,
|
||||
total_amount_usd: total_amount,
|
||||
licenses: license_entries,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user