use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use chrono::{NaiveDateTime, Utc};
#[cfg(test)]
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
use tracing::info;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
use uuid::Uuid;
use crate::config::get_config;
use crate::errors::{LicenseError, LicenseResult};
use crate::license_key::{generate_license_key, LicenseKeyConfig};
use crate::server::api_error::{ApiError, ErrorCode};
use crate::server::database::{Database, License};
use crate::server::handlers::AppState;
use crate::server::logging::{log_license_event, LicenseEvent};
use crate::tiers::get_tier_features;
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct CreateLicenseRequest {
pub org_id: Option<String>,
pub org_name: Option<String>,
pub tier: Option<String>,
#[serde(default)]
pub features: Vec<String>,
pub expires_at: Option<String>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct BatchCreateLicenseRequest {
pub count: u32,
pub org_id: Option<String>,
pub org_name: Option<String>,
pub tier: Option<String>,
#[serde(default)]
pub features: Vec<String>,
pub expires_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct UpdateLicenseRequest {
pub tier: Option<String>,
pub features: Option<Vec<String>>,
pub expires_at: Option<String>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct ListLicensesQuery {
pub org_id: Option<String>,
#[serde(default = "default_page")]
pub page: u32,
#[serde(default = "default_per_page")]
pub per_page: u32,
}
fn default_page() -> u32 {
1
}
fn default_per_page() -> u32 {
50
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LicenseResponse {
pub license_id: String,
pub license_key: Option<String>,
pub status: String,
pub org_id: Option<String>,
pub org_name: Option<String>,
pub tier: Option<String>,
pub features: Vec<String>,
pub issued_at: String,
pub expires_at: Option<String>,
pub is_bound: bool,
pub hardware_id: Option<String>,
pub device_name: Option<String>,
pub bound_at: Option<String>,
pub last_seen_at: Option<String>,
pub metadata: Option<serde_json::Value>,
}
impl From<License> for LicenseResponse {
fn from(license: License) -> Self {
let features: Vec<String> = license
.features
.as_ref()
.map(|f| serde_json::from_str(f).unwrap_or_default())
.unwrap_or_default();
let metadata: Option<serde_json::Value> = license
.metadata
.as_ref()
.and_then(|m| serde_json::from_str(m).ok());
let is_bound = license.is_bound();
Self {
license_id: license.license_id,
license_key: license.license_key,
status: license.status,
org_id: license.org_id,
org_name: license.org_name,
tier: license.tier,
features,
issued_at: license.issued_at.to_string(),
expires_at: license.expires_at.map(|d| d.to_string()),
is_bound,
hardware_id: license.hardware_id,
device_name: license.device_name,
bound_at: license.bound_at.map(|d| d.to_string()),
last_seen_at: license.last_seen_at.map(|d| d.to_string()),
metadata,
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct BatchCreateResponse {
pub created: u32,
pub licenses: Vec<LicenseSummary>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct LicenseSummary {
pub license_id: String,
pub license_key: String,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ListLicensesResponse {
pub licenses: Vec<LicenseResponse>,
pub total: u32,
pub page: u32,
pub per_page: u32,
pub total_pages: u32,
}
#[derive(Debug)]
pub enum AdminError {
NotFound(String),
BadRequest(String),
DatabaseError(String),
ConfigError(String),
}
impl std::fmt::Display for AdminError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AdminError::NotFound(msg) => write!(f, "not found: {msg}"),
AdminError::BadRequest(msg) => write!(f, "bad request: {msg}"),
AdminError::DatabaseError(msg) => write!(f, "database error: {msg}"),
AdminError::ConfigError(msg) => write!(f, "configuration error: {msg}"),
}
}
}
impl std::error::Error for AdminError {}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
let api_error: ApiError = self.into();
api_error.into_response()
}
}
impl From<AdminError> for ApiError {
fn from(err: AdminError) -> Self {
match err {
AdminError::NotFound(msg) => ApiError::with_message(ErrorCode::NotFound, msg),
AdminError::BadRequest(msg) => ApiError::with_message(ErrorCode::InvalidRequest, msg),
AdminError::DatabaseError(msg) => ApiError::with_message(ErrorCode::DatabaseError, msg),
AdminError::ConfigError(msg) => ApiError::with_message(ErrorCode::ConfigError, msg),
}
}
}
impl From<LicenseError> for AdminError {
fn from(err: LicenseError) -> Self {
match err {
LicenseError::ConfigError(msg) => AdminError::ConfigError(msg),
LicenseError::ServerError(msg) => AdminError::DatabaseError(msg),
_ => AdminError::DatabaseError(err.to_string()),
}
}
}
fn parse_datetime(s: &str) -> Result<NaiveDateTime, AdminError> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Ok(dt.naive_utc());
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Ok(date.and_hms_opt(23, 59, 59).unwrap());
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
return Ok(dt);
}
Err(AdminError::BadRequest(format!(
"invalid datetime format: {s}. Use ISO 8601 (e.g., '2025-12-31T23:59:59Z' or '2025-12-31')"
)))
}
fn resolve_features(tier: Option<&str>, explicit_features: &[String]) -> Vec<String> {
let mut features: Vec<String> = if let Some(tier_name) = tier {
get_tier_features(tier_name)
} else {
Vec::new()
};
for feature in explicit_features {
if !features.contains(feature) {
features.push(feature.clone());
}
}
features
}
async fn generate_unique_license_key(db: &Database) -> LicenseResult<String> {
let config = get_config()?;
let key_config: LicenseKeyConfig = (&config.license).into();
for _ in 0..10 {
let key = generate_license_key(&key_config);
if !db.license_key_exists(&key).await? {
return Ok(key);
}
}
Err(LicenseError::ServerError(
"failed to generate unique license key after 10 attempts".to_string(),
))
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/licenses",
tag = "admin",
request_body = CreateLicenseRequest,
responses(
(status = 201, description = "License created", body = LicenseResponse),
(status = 400, description = "Invalid request"),
(status = 500, description = "Server error"),
),
security(("bearer_auth" = []))
))]
pub async fn create_license_handler(
State(state): State<AppState>,
Json(payload): Json<CreateLicenseRequest>,
) -> Result<(StatusCode, Json<LicenseResponse>), AdminError> {
info!("Creating new license for org_id={:?}", payload.org_id);
let now = Utc::now().naive_utc();
let license_id = Uuid::new_v4().to_string();
let license_key = generate_unique_license_key(&state.db).await?;
let expires_at = payload
.expires_at
.as_ref()
.map(|s| parse_datetime(s))
.transpose()?;
let features = resolve_features(payload.tier.as_deref(), &payload.features);
let features_json = serde_json::to_string(&features).ok();
let metadata_json = payload
.metadata
.as_ref()
.and_then(|m| serde_json::to_string(m).ok());
let license = License {
license_id: license_id.clone(),
client_id: None,
status: "active".to_string(),
features: features_json,
issued_at: now,
expires_at,
hardware_id: None,
signature: None,
last_heartbeat: None,
org_id: payload.org_id,
org_name: payload.org_name,
license_key: Some(license_key.clone()),
tier: payload.tier,
device_name: None,
device_info: None,
bound_at: None,
last_seen_at: None,
suspended_at: None,
revoked_at: None,
revoke_reason: None,
grace_period_ends_at: None,
suspension_message: None,
is_blacklisted: None,
blacklisted_at: None,
blacklist_reason: None,
metadata: metadata_json,
bandwidth_used_bytes: None,
bandwidth_limit_bytes: None,
quota_exceeded: None,
};
state.db.insert_license(license.clone()).await?;
log_license_event(LicenseEvent::Created, &license_id, Some(&license_key));
Ok((StatusCode::CREATED, Json(license.into())))
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/licenses/batch",
tag = "admin",
request_body = BatchCreateLicenseRequest,
responses(
(status = 201, description = "Licenses created", body = BatchCreateResponse),
(status = 400, description = "Invalid request"),
(status = 500, description = "Server error"),
),
security(("bearer_auth" = []))
))]
pub async fn batch_create_license_handler(
State(state): State<AppState>,
Json(payload): Json<BatchCreateLicenseRequest>,
) -> Result<(StatusCode, Json<BatchCreateResponse>), AdminError> {
if payload.count == 0 {
return Err(AdminError::BadRequest(
"count must be greater than 0".to_string(),
));
}
if payload.count > 1000 {
return Err(AdminError::BadRequest(
"count must not exceed 1000".to_string(),
));
}
info!(
"Batch creating {} licenses for org_id={:?}",
payload.count, payload.org_id
);
let now = Utc::now().naive_utc();
let expires_at = payload
.expires_at
.as_ref()
.map(|s| parse_datetime(s))
.transpose()?;
let features = resolve_features(payload.tier.as_deref(), &payload.features);
let features_json = serde_json::to_string(&features).ok();
let mut licenses = Vec::with_capacity(payload.count as usize);
for _ in 0..payload.count {
let license_id = Uuid::new_v4().to_string();
let license_key = generate_unique_license_key(&state.db).await?;
let license = License {
license_id: license_id.clone(),
client_id: None,
status: "active".to_string(),
features: features_json.clone(),
issued_at: now,
expires_at,
hardware_id: None,
signature: None,
last_heartbeat: None,
org_id: payload.org_id.clone(),
org_name: payload.org_name.clone(),
license_key: Some(license_key.clone()),
tier: payload.tier.clone(),
device_name: None,
device_info: None,
bound_at: None,
last_seen_at: None,
suspended_at: None,
revoked_at: None,
revoke_reason: None,
grace_period_ends_at: None,
suspension_message: None,
is_blacklisted: None,
blacklisted_at: None,
blacklist_reason: None,
metadata: None,
bandwidth_used_bytes: None,
bandwidth_limit_bytes: None,
quota_exceeded: None,
};
state.db.insert_license(license).await?;
licenses.push(LicenseSummary {
license_id,
license_key,
});
}
info!("Batch created {} licenses", licenses.len());
Ok((
StatusCode::CREATED,
Json(BatchCreateResponse {
created: licenses.len() as u32,
licenses,
}),
))
}
#[cfg_attr(feature = "openapi", utoipa::path(
get,
path = "/api/v1/licenses/{license_id}",
tag = "admin",
params(
("license_id" = String, Path, description = "License ID")
),
responses(
(status = 200, description = "License details", body = LicenseResponse),
(status = 404, description = "License not found"),
(status = 500, description = "Server error"),
),
security(("bearer_auth" = []))
))]
pub async fn get_license_handler(
State(state): State<AppState>,
Path(license_id): Path<String>,
) -> Result<Json<LicenseResponse>, AdminError> {
info!("Getting license license_id={}", license_id);
let license = state
.db
.get_license(&license_id)
.await?
.ok_or_else(|| AdminError::NotFound(format!("license not found: {license_id}")))?;
Ok(Json(license.into()))
}
#[cfg_attr(feature = "openapi", utoipa::path(
get,
path = "/api/v1/licenses",
tag = "admin",
params(
("org_id" = Option<String>, Query, description = "Filter by organization ID"),
("page" = Option<u32>, Query, description = "Page number (1-indexed)"),
("per_page" = Option<u32>, Query, description = "Items per page")
),
responses(
(status = 200, description = "List of licenses", body = ListLicensesResponse),
(status = 400, description = "Invalid request"),
(status = 500, description = "Server error"),
),
security(("bearer_auth" = []))
))]
pub async fn list_licenses_handler(
State(state): State<AppState>,
Query(query): Query<ListLicensesQuery>,
) -> Result<Json<ListLicensesResponse>, AdminError> {
info!(
"Listing licenses org_id={:?} page={} per_page={}",
query.org_id, query.page, query.per_page
);
let licenses = if let Some(org_id) = &query.org_id {
state.db.list_licenses_by_org(org_id).await?
} else {
return Err(AdminError::BadRequest(
"org_id query parameter is required".to_string(),
));
};
let total = licenses.len() as u32;
let total_pages = total.div_ceil(query.per_page);
let start = ((query.page.saturating_sub(1)) * query.per_page) as usize;
let end = (start + query.per_page as usize).min(licenses.len());
let page_licenses: Vec<LicenseResponse> = licenses
.into_iter()
.skip(start)
.take(end - start)
.map(|l| l.into())
.collect();
Ok(Json(ListLicensesResponse {
licenses: page_licenses,
total,
page: query.page,
per_page: query.per_page,
total_pages,
}))
}
#[cfg_attr(feature = "openapi", utoipa::path(
patch,
path = "/api/v1/licenses/{license_id}",
tag = "admin",
params(
("license_id" = String, Path, description = "License ID")
),
request_body = UpdateLicenseRequest,
responses(
(status = 200, description = "License updated", body = LicenseResponse),
(status = 404, description = "License not found"),
(status = 500, description = "Server error"),
),
security(("bearer_auth" = []))
))]
pub async fn update_license_handler(
State(state): State<AppState>,
Path(license_id): Path<String>,
Json(payload): Json<UpdateLicenseRequest>,
) -> Result<Json<LicenseResponse>, AdminError> {
info!("Updating license license_id={}", license_id);
let mut license = state
.db
.get_license(&license_id)
.await?
.ok_or_else(|| AdminError::NotFound(format!("license not found: {license_id}")))?;
if let Some(tier) = &payload.tier {
license.tier = Some(tier.clone());
if payload.features.is_none() {
let features = resolve_features(Some(tier), &[]);
license.features = serde_json::to_string(&features).ok();
}
}
if let Some(features) = &payload.features {
let final_features = if let Some(tier) = &payload.tier {
resolve_features(Some(tier), features)
} else {
features.clone()
};
license.features = serde_json::to_string(&final_features).ok();
}
if let Some(expires_at_str) = &payload.expires_at {
license.expires_at = Some(parse_datetime(expires_at_str)?);
}
if let Some(metadata) = &payload.metadata {
license.metadata = serde_json::to_string(metadata).ok();
}
state.db.insert_license(license.clone()).await?;
info!("Updated license license_id={}", license_id);
Ok(Json(license.into()))
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AdminReleaseRequest {
pub reason: Option<String>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AdminReleaseResponse {
pub success: bool,
pub message: String,
pub previous_hardware_id: Option<String>,
pub previous_device_name: Option<String>,
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/licenses/{license_id}/release",
tag = "admin",
params(
("license_id" = String, Path, description = "License ID")
),
request_body = AdminReleaseRequest,
responses(
(status = 200, description = "License released", body = AdminReleaseResponse),
(status = 400, description = "License not bound"),
(status = 404, description = "License not found"),
),
security(("bearer_auth" = []))
))]
pub async fn admin_release_handler(
State(state): State<AppState>,
Path(license_id): Path<String>,
Json(payload): Json<AdminReleaseRequest>,
) -> Result<Json<AdminReleaseResponse>, AdminError> {
use crate::server::database::{BindingAction, PerformedBy};
info!("Admin release request for license_id={}", license_id);
let license = state
.db
.get_license(&license_id)
.await?
.ok_or_else(|| AdminError::NotFound(format!("License {license_id} not found")))?;
if !license.is_bound() {
return Err(AdminError::BadRequest(
"License is not currently bound".to_string(),
));
}
let previous_hardware_id = license.hardware_id.clone();
let previous_device_name = license.device_name.clone();
state.db.release_license(&license_id).await?;
let _ = state
.db
.record_binding_history(
&license_id,
BindingAction::AdminRelease,
previous_hardware_id.as_deref(),
previous_device_name.as_deref(),
license.device_info.as_deref(),
PerformedBy::Admin,
payload.reason.as_deref(),
)
.await;
info!(
"Admin released license {} from hardware {:?}",
license_id, previous_hardware_id
);
Ok(Json(AdminReleaseResponse {
success: true,
message: "License released successfully".to_string(),
previous_hardware_id,
previous_device_name,
}))
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct RevokeLicenseRequest {
pub reason: Option<String>,
#[serde(default)]
pub grace_period_days: u32,
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct RevokeLicenseResponse {
pub success: bool,
pub status: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub grace_period_ends_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReinstateLicenseRequest {
pub new_expires_at: Option<String>,
#[serde(default)]
pub reset_bandwidth: bool,
pub reason: Option<String>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReinstateLicenseResponse {
pub success: bool,
pub status: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ExtendLicenseRequest {
pub new_expires_at: String,
#[serde(default)]
pub reset_bandwidth: bool,
pub reason: Option<String>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ExtendLicenseResponse {
pub success: bool,
pub message: String,
pub previous_expires_at: Option<String>,
pub new_expires_at: String,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct UpdateUsageRequest {
pub bandwidth_used_bytes: Option<u64>,
pub bandwidth_limit_bytes: Option<u64>,
#[serde(default)]
pub reset: bool,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct UpdateUsageResponse {
pub success: bool,
pub bandwidth_used_bytes: u64,
pub bandwidth_limit_bytes: Option<u64>,
pub quota_exceeded: bool,
pub usage_percentage: Option<f64>,
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/licenses/{license_id}/revoke",
tag = "admin",
params(
("license_id" = String, Path, description = "License ID")
),
request_body = RevokeLicenseRequest,
responses(
(status = 200, description = "License revoked", body = RevokeLicenseResponse),
(status = 400, description = "License already revoked"),
(status = 404, description = "License not found"),
),
security(("bearer_auth" = []))
))]
pub async fn revoke_license_handler(
State(state): State<AppState>,
Path(license_id): Path<String>,
Json(payload): Json<RevokeLicenseRequest>,
) -> Result<Json<RevokeLicenseResponse>, AdminError> {
info!(
"Revoke request for license_id={} grace_period_days={}",
license_id, payload.grace_period_days
);
let mut license = state
.db
.get_license(&license_id)
.await?
.ok_or_else(|| AdminError::NotFound(format!("License {license_id} not found")))?;
if license.status == "revoked" {
return Err(AdminError::BadRequest(
"License is already revoked".to_string(),
));
}
let now = Utc::now().naive_utc();
if payload.grace_period_days == 0 {
license.status = "revoked".to_string();
license.revoked_at = Some(now);
license.revoke_reason = payload.reason.clone();
license.suspended_at = None;
license.grace_period_ends_at = None;
license.suspension_message = None;
state.db.insert_license(license).await?;
log_license_event(
LicenseEvent::Revoked,
&license_id,
payload.reason.as_deref(),
);
Ok(Json(RevokeLicenseResponse {
success: true,
status: "revoked".to_string(),
message: "License has been revoked".to_string(),
grace_period_ends_at: None,
}))
} else {
let grace_end = now + chrono::Duration::days(payload.grace_period_days as i64);
license.status = "suspended".to_string();
license.suspended_at = Some(now);
license.grace_period_ends_at = Some(grace_end);
license.revoke_reason = payload.reason.clone();
license.suspension_message = payload.message.clone();
state.db.insert_license(license).await?;
log_license_event(
LicenseEvent::Suspended,
&license_id,
Some(&format!("grace period until {}", grace_end)),
);
Ok(Json(RevokeLicenseResponse {
success: true,
status: "suspended".to_string(),
message: format!(
"License has been suspended with {} day grace period",
payload.grace_period_days
),
grace_period_ends_at: Some(grace_end.and_utc().to_rfc3339()),
}))
}
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/licenses/{license_id}/reinstate",
tag = "admin",
params(
("license_id" = String, Path, description = "License ID")
),
request_body = ReinstateLicenseRequest,
responses(
(status = 200, description = "License reinstated", body = ReinstateLicenseResponse),
(status = 400, description = "License already active or blacklisted"),
(status = 404, description = "License not found"),
),
security(("bearer_auth" = []))
))]
pub async fn reinstate_license_handler(
State(state): State<AppState>,
Path(license_id): Path<String>,
Json(payload): Json<ReinstateLicenseRequest>,
) -> Result<Json<ReinstateLicenseResponse>, AdminError> {
info!(
"Reinstate request for license_id={} reset_bandwidth={}",
license_id, payload.reset_bandwidth
);
let mut license = state
.db
.get_license(&license_id)
.await?
.ok_or_else(|| AdminError::NotFound(format!("License {license_id} not found")))?;
if license.is_blacklisted == Some(true) {
return Err(AdminError::BadRequest(
"Cannot reinstate a blacklisted license. Remove from blacklist first.".to_string(),
));
}
if license.status == "active" {
return Err(AdminError::BadRequest(
"License is already active".to_string(),
));
}
license.status = "active".to_string();
license.suspended_at = None;
license.revoked_at = None;
license.revoke_reason = None;
license.grace_period_ends_at = None;
license.suspension_message = None;
let expires_at_str = if let Some(new_expires_at) = &payload.new_expires_at {
let expires_at = parse_datetime(new_expires_at)?;
license.expires_at = Some(expires_at);
Some(expires_at.to_string())
} else {
license.expires_at.map(|dt| dt.to_string())
};
if payload.reset_bandwidth {
info!(
"Bandwidth reset requested for license {} (no-op for now)",
license_id
);
}
state.db.insert_license(license).await?;
log_license_event(
LicenseEvent::Reinstated,
&license_id,
payload.reason.as_deref(),
);
Ok(Json(ReinstateLicenseResponse {
success: true,
status: "active".to_string(),
message: "License has been reinstated".to_string(),
expires_at: expires_at_str,
}))
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/licenses/{license_id}/extend",
tag = "admin",
params(
("license_id" = String, Path, description = "License ID")
),
request_body = ExtendLicenseRequest,
responses(
(status = 200, description = "License extended", body = ExtendLicenseResponse),
(status = 400, description = "Invalid date format"),
(status = 404, description = "License not found"),
),
security(("bearer_auth" = []))
))]
pub async fn extend_license_handler(
State(state): State<AppState>,
Path(license_id): Path<String>,
Json(payload): Json<ExtendLicenseRequest>,
) -> Result<Json<ExtendLicenseResponse>, AdminError> {
info!(
"Extend request for license_id={} new_expires_at={}",
license_id, payload.new_expires_at
);
let mut license = state
.db
.get_license(&license_id)
.await?
.ok_or_else(|| AdminError::NotFound(format!("License {license_id} not found")))?;
let new_expires_at = parse_datetime(&payload.new_expires_at)?;
let previous_expires_at = license.expires_at.map(|dt| dt.to_string());
license.expires_at = Some(new_expires_at);
if payload.reset_bandwidth {
info!(
"Bandwidth reset requested for license {} (no-op for now)",
license_id
);
}
state.db.insert_license(license).await?;
log_license_event(
LicenseEvent::Extended,
&license_id,
Some(&format!("extended to {}", new_expires_at)),
);
Ok(Json(ExtendLicenseResponse {
success: true,
message: "License expiration has been extended".to_string(),
previous_expires_at,
new_expires_at: new_expires_at.to_string(),
}))
}
#[cfg_attr(feature = "openapi", utoipa::path(
patch,
path = "/api/v1/licenses/{license_id}/usage",
tag = "admin",
params(
("license_id" = String, Path, description = "License ID")
),
request_body = UpdateUsageRequest,
responses(
(status = 200, description = "Usage updated", body = UpdateUsageResponse),
(status = 404, description = "License not found"),
(status = 500, description = "Server error"),
),
security(("bearer_auth" = []))
))]
pub async fn update_usage_handler(
State(state): State<AppState>,
Path(license_id): Path<String>,
Json(payload): Json<UpdateUsageRequest>,
) -> Result<Json<UpdateUsageResponse>, AdminError> {
info!(
"Update usage request for license_id={} used={:?} limit={:?} reset={}",
license_id, payload.bandwidth_used_bytes, payload.bandwidth_limit_bytes, payload.reset
);
let license = state
.db
.get_license(&license_id)
.await?
.ok_or_else(|| AdminError::NotFound(format!("License {license_id} not found")))?;
let bandwidth_used_bytes: u64 = if payload.reset {
0
} else {
payload
.bandwidth_used_bytes
.unwrap_or_else(|| license.bandwidth_used_bytes.unwrap_or(0) as u64)
};
let bandwidth_limit_bytes: Option<u64> = payload
.bandwidth_limit_bytes
.or_else(|| license.bandwidth_limit_bytes.map(|v| v as u64));
let quota_exceeded = match bandwidth_limit_bytes {
Some(limit) if limit > 0 => bandwidth_used_bytes >= limit,
_ => false,
};
let usage_percentage = bandwidth_limit_bytes.map(|limit| {
if limit > 0 {
(bandwidth_used_bytes as f64 / limit as f64) * 100.0
} else {
0.0
}
});
state
.db
.update_usage(
&license_id,
bandwidth_used_bytes as i64,
bandwidth_limit_bytes.map(|v| v as i64),
quota_exceeded,
)
.await?;
info!(
"Usage updated for license {}: used={} limit={:?} exceeded={}",
license_id, bandwidth_used_bytes, bandwidth_limit_bytes, quota_exceeded
);
Ok(Json(UpdateUsageResponse {
success: true,
bandwidth_used_bytes,
bandwidth_limit_bytes,
quota_exceeded,
usage_percentage,
}))
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct BlacklistLicenseRequest {
pub reason: String,
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct BlacklistLicenseResponse {
pub success: bool,
pub message: String,
pub status: String,
pub blacklisted_at: String,
}
#[cfg_attr(feature = "openapi", utoipa::path(
post,
path = "/api/v1/licenses/{license_id}/blacklist",
tag = "admin",
params(
("license_id" = String, Path, description = "License ID")
),
request_body = BlacklistLicenseRequest,
responses(
(status = 200, description = "License blacklisted", body = BlacklistLicenseResponse),
(status = 400, description = "License already blacklisted or reason empty"),
(status = 404, description = "License not found"),
),
security(("bearer_auth" = []))
))]
pub async fn blacklist_license_handler(
State(state): State<AppState>,
Path(license_id): Path<String>,
Json(payload): Json<BlacklistLicenseRequest>,
) -> Result<Json<BlacklistLicenseResponse>, AdminError> {
use crate::server::database::{BindingAction, PerformedBy};
info!(
"Blacklist request for license_id={} reason={}",
license_id, payload.reason
);
if payload.reason.trim().is_empty() {
return Err(AdminError::BadRequest(
"reason is required for blacklisting".to_string(),
));
}
let mut license = state
.db
.get_license(&license_id)
.await?
.ok_or_else(|| AdminError::NotFound(format!("License {license_id} not found")))?;
if license.is_blacklisted == Some(true) {
return Err(AdminError::BadRequest(
"License is already blacklisted".to_string(),
));
}
let now = Utc::now().naive_utc();
if license.is_bound() {
let _ = state
.db
.record_binding_history(
&license_id,
BindingAction::AdminRelease,
license.hardware_id.as_deref(),
license.device_name.as_deref(),
license.device_info.as_deref(),
PerformedBy::Admin,
Some(&format!("Blacklisted: {}", payload.reason)),
)
.await;
}
license.is_blacklisted = Some(true);
license.blacklisted_at = Some(now);
license.blacklist_reason = Some(payload.reason.clone());
license.status = "revoked".to_string();
license.revoked_at = Some(now);
license.revoke_reason = Some(format!("Blacklisted: {}", payload.reason));
if let Some(msg) = &payload.message {
license.suspension_message = Some(msg.clone());
}
license.hardware_id = None;
license.device_name = None;
license.device_info = None;
license.bound_at = None;
state.db.insert_license(license).await?;
log_license_event(
LicenseEvent::Blacklisted,
&license_id,
Some(&payload.reason),
);
Ok(Json(BlacklistLicenseResponse {
success: true,
message: "License has been blacklisted".to_string(),
status: "revoked".to_string(),
blacklisted_at: now.to_string(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_datetime_rfc3339() {
let dt = parse_datetime("2025-12-31T23:59:59Z").unwrap();
assert_eq!(dt.year(), 2025);
assert_eq!(dt.month(), 12);
assert_eq!(dt.day(), 31);
}
#[test]
fn parse_datetime_date_only() {
let dt = parse_datetime("2025-12-31").unwrap();
assert_eq!(dt.year(), 2025);
assert_eq!(dt.month(), 12);
assert_eq!(dt.day(), 31);
assert_eq!(dt.hour(), 23);
assert_eq!(dt.minute(), 59);
}
#[test]
fn parse_datetime_invalid() {
assert!(parse_datetime("invalid").is_err());
assert!(parse_datetime("2025/12/31").is_err());
}
#[test]
fn resolve_features_no_tier() {
let features = resolve_features(None, &["feature_a".to_string()]);
assert_eq!(features, vec!["feature_a"]);
}
#[test]
fn resolve_features_merges_without_duplicates() {
let features = resolve_features(
Some("nonexistent"),
&["feature_a".to_string(), "feature_b".to_string()],
);
assert_eq!(features, vec!["feature_a", "feature_b"]);
}
#[test]
fn license_response_from_license() {
let license = License {
license_id: "test-id".to_string(),
client_id: None,
status: "active".to_string(),
features: Some(r#"["feature_a","feature_b"]"#.to_string()),
issued_at: Utc::now().naive_utc(),
expires_at: None,
hardware_id: Some("hw-123".to_string()),
signature: None,
last_heartbeat: None,
org_id: Some("org-1".to_string()),
org_name: Some("Test Org".to_string()),
license_key: Some("LIC-AAAA-BBBB-CCCC".to_string()),
tier: Some("pro".to_string()),
device_name: Some("Test Device".to_string()),
device_info: None,
bound_at: Some(Utc::now().naive_utc()),
last_seen_at: None,
suspended_at: None,
revoked_at: None,
revoke_reason: None,
grace_period_ends_at: None,
suspension_message: None,
is_blacklisted: None,
blacklisted_at: None,
blacklist_reason: None,
metadata: Some(r#"{"key":"value"}"#.to_string()),
bandwidth_used_bytes: None,
bandwidth_limit_bytes: None,
quota_exceeded: None,
};
let response: LicenseResponse = license.into();
assert_eq!(response.license_id, "test-id");
assert_eq!(response.status, "active");
assert_eq!(response.features, vec!["feature_a", "feature_b"]);
assert_eq!(response.org_id, Some("org-1".to_string()));
assert_eq!(response.tier, Some("pro".to_string()));
assert!(response.is_bound);
assert!(response.metadata.is_some());
}
#[test]
fn admin_error_display() {
assert!(AdminError::NotFound("test".to_string())
.to_string()
.contains("not found"));
assert!(AdminError::BadRequest("test".to_string())
.to_string()
.contains("bad request"));
}
}