feat: Implement Phase 6 B2B hosting integration (minimal viable B2B)
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:
Vantz Stockwell
2026-02-15 15:05:17 -05:00
parent a8b7f536b5
commit 071ab80e40
7 changed files with 842 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
-- Phase 6: B2B Hosting Integration (Minimal Viable B2B)
-- Hosting provider accounts (resellers, hosting companies)
CREATE TABLE IF NOT EXISTS hosts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_name VARCHAR(200) NOT NULL,
contact_email VARCHAR(255) UNIQUE NOT NULL,
api_key VARCHAR(64) UNIQUE NOT NULL, -- SHA-256 hash of API key
wholesale_rate_usd DECIMAL(10,2) DEFAULT 6.00, -- $6/server/month default
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Host-provisioned licenses (tracks which licenses were provisioned by which host)
CREATE TABLE IF NOT EXISTS host_licenses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
server_identifier VARCHAR(255), -- Host's internal server ID (e.g., "rust-nyc-01")
customer_email VARCHAR(255), -- End customer email (for host's records)
provisioned_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ, -- Updated when server heartbeat received
UNIQUE(host_id, license_id)
);
-- Monthly billing records (for invoice generation)
CREATE TABLE IF NOT EXISTS host_billing_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
billing_month DATE NOT NULL, -- First day of month (e.g., 2026-02-01)
active_license_count INTEGER NOT NULL,
wholesale_rate_usd DECIMAL(10,2) NOT NULL,
total_amount_usd DECIMAL(10,2) NOT NULL, -- active_license_count * wholesale_rate_usd
generated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(host_id, billing_month)
);
CREATE INDEX idx_hosts_api_key ON hosts(api_key);
CREATE INDEX idx_host_licenses_host ON host_licenses(host_id);
CREATE INDEX idx_host_licenses_license ON host_licenses(license_id);
CREATE INDEX idx_host_billing_records_host_month ON host_billing_records(host_id, billing_month);
COMMENT ON TABLE hosts IS 'Phase 6: Hosting provider accounts for B2B reseller program';
COMMENT ON TABLE host_licenses IS 'Phase 6: Tracks licenses provisioned by hosting providers';
COMMENT ON TABLE host_billing_records IS 'Phase 6: Monthly billing data for hosting providers ($6/server wholesale)';

256
backend/src/api/host.rs Normal file
View 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,
}))
}

View File

@@ -20,3 +20,4 @@ pub mod settings;
pub mod modules;
pub mod webstore;
pub mod public_store;
pub mod host;

View File

@@ -134,6 +134,7 @@ async fn main() -> anyhow::Result<()> {
.nest("/api/modules", api::modules::router())
.nest("/api/webstore", api::webstore::router())
.nest("/api/public-store", api::public_store::router())
.nest("/api/host", api::host::router())
.layer(cors)
.layer(TraceLayer::new_for_http())
.with_state(state);

View File

@@ -0,0 +1,232 @@
use anyhow::{Context, Result, bail};
use sqlx::PgPool;
use uuid::Uuid;
/// Bulk license provisioning service for hosting providers (B2B Model B)
pub struct HostProvisioningService {
db: PgPool,
}
impl HostProvisioningService {
pub fn new(db: PgPool) -> Self {
Self { db }
}
/// Verify host API key and return host_id
pub async fn authenticate_host(&self, api_key: &str) -> Result<Uuid> {
// Hash the provided API key
let api_key_hash = sha256::digest(api_key);
let host = sqlx::query!(
"SELECT id FROM hosts WHERE api_key = $1 AND active = true",
api_key_hash
)
.fetch_optional(&self.db)
.await
.context("Failed to verify host API key")?;
host.map(|h| h.id)
.ok_or_else(|| anyhow::anyhow!("Invalid or inactive host API key"))
}
/// Provision a new license for a hosting customer
pub async fn provision_license(
&self,
host_id: Uuid,
server_identifier: &str,
customer_email: &str,
) -> Result<ProvisionedLicense> {
// Generate license key (CORROSION-XXXXXXXX format)
let license_key = format!(
"CORROSION-{}",
crate::services::encryption::generate_token(8).to_uppercase()
);
// Generate companion token (for bare metal agent authentication)
let companion_token = crate::services::encryption::generate_token(32);
// Create user account for customer (if doesn't exist)
let user_id = self
.get_or_create_user(customer_email, &license_key)
.await?;
// Create license
let license_id = sqlx::query_scalar!(
"INSERT INTO licenses (license_key, owner_id, server_name, subdomain)
VALUES ($1, $2, $3, $4)
RETURNING id",
license_key,
user_id,
server_identifier,
self.generate_subdomain(server_identifier),
)
.fetch_one(&self.db)
.await
.context("Failed to create license")?;
// Associate license with host
sqlx::query!(
"INSERT INTO host_licenses (host_id, license_id, server_identifier, customer_email)
VALUES ($1, $2, $3, $4)",
host_id,
license_id,
server_identifier,
customer_email
)
.execute(&self.db)
.await
.context("Failed to link license to host")?;
// Store companion token
sqlx::query!(
"INSERT INTO server_connections (license_id, connection_type, auth_token)
VALUES ($1, 'bare_metal', $2)
ON CONFLICT (license_id) DO UPDATE SET auth_token = $2",
license_id,
companion_token
)
.execute(&self.db)
.await
.context("Failed to store companion token")?;
tracing::info!(
"Provisioned license {} for host {} (server: {}, customer: {})",
license_key,
host_id,
server_identifier,
customer_email
);
Ok(ProvisionedLicense {
license_key,
companion_token,
plugin_download_url: format!(
"https://cdn.corrosionmgmt.com/plugin/CorrosionCompanion-latest.cs"
),
subdomain: self.generate_subdomain(server_identifier),
})
}
/// Get existing user or create new one
async fn get_or_create_user(&self, email: &str, license_key: &str) -> Result<Uuid> {
// Check if user exists
if let Some(user) = sqlx::query!("SELECT id FROM users WHERE email = $1", email)
.fetch_optional(&self.db)
.await?
{
return Ok(user.id);
}
// Create new user with random password (they'll reset via email)
let random_password = crate::services::encryption::generate_token(16);
let password_hash = crate::services::auth::hash_password(&random_password)?;
// Extract username from email
let username = email.split('@').next().unwrap_or("user");
let user_id = crate::db::users::create_user(&self.db, email, username, &password_hash)
.await
.context("Failed to create user account")?;
// TODO: Send welcome email with password reset link
Ok(user_id)
}
/// Generate subdomain from server identifier
fn generate_subdomain(&self, server_identifier: &str) -> String {
// Sanitize: lowercase, replace non-alphanumeric with hyphens, trim hyphens
server_identifier
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string()
}
/// Get active license count for a host (for billing)
pub async fn get_active_license_count(&self, host_id: Uuid) -> Result<i64> {
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM host_licenses hl
INNER JOIN licenses l ON l.id = hl.license_id
WHERE hl.host_id = $1 AND l.active = true",
host_id
)
.fetch_one(&self.db)
.await?
.unwrap_or(0);
Ok(count)
}
/// Generate monthly billing record for all hosts
pub async fn generate_monthly_billing(&self, billing_month: chrono::NaiveDate) -> Result<Vec<HostBillingRecord>> {
let hosts = sqlx::query!("SELECT id, company_name, wholesale_rate_usd FROM hosts WHERE active = true")
.fetch_all(&self.db)
.await?;
let mut records = Vec::new();
for host in hosts {
let active_count = self.get_active_license_count(host.id).await?;
if active_count == 0 {
continue; // Skip hosts with no active licenses
}
let wholesale_rate = host.wholesale_rate_usd;
let total_amount = wholesale_rate * rust_decimal::Decimal::from(active_count);
// Insert billing record
sqlx::query!(
"INSERT INTO host_billing_records (host_id, billing_month, active_license_count, wholesale_rate_usd, total_amount_usd)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (host_id, billing_month) DO UPDATE
SET active_license_count = $3, total_amount_usd = $5",
host.id,
billing_month,
active_count as i32,
wholesale_rate,
total_amount
)
.execute(&self.db)
.await?;
records.push(HostBillingRecord {
host_id: host.id,
company_name: host.company_name,
active_license_count: active_count as i32,
wholesale_rate_usd: wholesale_rate,
total_amount_usd: total_amount,
});
tracing::info!(
"Generated billing record for host {} ({} servers @ ${}/mo = ${})",
host.company_name,
active_count,
wholesale_rate,
total_amount
);
}
Ok(records)
}
}
#[derive(Debug)]
pub struct ProvisionedLicense {
pub license_key: String,
pub companion_token: String,
pub plugin_download_url: String,
pub subdomain: String,
}
#[derive(Debug)]
pub struct HostBillingRecord {
pub host_id: Uuid,
pub company_name: String,
pub active_license_count: i32,
pub wholesale_rate_usd: rust_decimal::Decimal,
pub total_amount_usd: rust_decimal::Decimal,
}

View File

@@ -20,3 +20,4 @@ pub mod alerting;
pub mod module_installer;
pub mod payment_processor;
pub mod subscription_processor;
pub mod host_provisioning;