feat: Implement Phase 4 module licensing backend
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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>
This commit is contained in:
@@ -17,3 +17,4 @@ pub mod ws;
|
||||
pub mod analytics;
|
||||
pub mod plugin;
|
||||
pub mod settings;
|
||||
pub mod modules;
|
||||
|
||||
232
backend/src/api/modules.rs
Normal file
232
backend/src/api/modules.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db;
|
||||
use crate::middleware::auth::AuthUser;
|
||||
use crate::models::error::{ApiError, ApiResult};
|
||||
use crate::models::modules::{ModuleWithOwnership, PurchasedModule};
|
||||
use crate::services::module_installer::ModuleInstaller;
|
||||
use crate::services::nats_bridge::NatsBridge;
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/catalog", get(get_catalog))
|
||||
.route("/my-modules", get(get_my_modules))
|
||||
.route("/purchase", post(purchase_module))
|
||||
.route("/install", post(install_module))
|
||||
.route("/:module_id/installation-status", get(get_installation_status))
|
||||
}
|
||||
|
||||
/// GET /api/modules/catalog
|
||||
/// Returns all modules with is_purchased flag for the authenticated user's license.
|
||||
async fn get_catalog(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<CatalogResponse>> {
|
||||
let license_id = auth.license_id.ok_or_else(|| {
|
||||
ApiError::BadRequest("No license associated with this user".to_string())
|
||||
})?;
|
||||
|
||||
let catalog = db::modules::get_catalog_with_ownership(&state.db, license_id).await?;
|
||||
|
||||
Ok(Json(CatalogResponse { modules: catalog }))
|
||||
}
|
||||
|
||||
/// GET /api/modules/my-modules
|
||||
/// Returns purchased modules with installation status.
|
||||
async fn get_my_modules(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<MyModulesResponse>> {
|
||||
let license_id = auth.license_id.ok_or_else(|| {
|
||||
ApiError::BadRequest("No license associated with this user".to_string())
|
||||
})?;
|
||||
|
||||
let modules = db::modules::get_purchased_modules(&state.db, license_id).await?;
|
||||
|
||||
Ok(Json(MyModulesResponse { modules }))
|
||||
}
|
||||
|
||||
/// POST /api/modules/purchase
|
||||
/// Initiate module purchase (stub for Phase 4 — payment integration is XO's direct touch).
|
||||
/// For MVP, immediately records the purchase without payment gateway.
|
||||
async fn purchase_module(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<PurchaseRequest>,
|
||||
) -> ApiResult<Json<PurchaseResponse>> {
|
||||
let license_id = auth.license_id.ok_or_else(|| {
|
||||
ApiError::BadRequest("No license associated with this user".to_string())
|
||||
})?;
|
||||
|
||||
// Verify module exists
|
||||
let module = db::modules::get_module_by_id(&state.db, req.module_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("Module not found".to_string()))?;
|
||||
|
||||
// Check if already purchased
|
||||
let already_purchased = db::modules::is_module_purchased(&state.db, license_id, req.module_id).await?;
|
||||
if already_purchased {
|
||||
return Err(ApiError::Conflict("Module already purchased".to_string()));
|
||||
}
|
||||
|
||||
// Record purchase (no payment gateway for MVP — stub transaction)
|
||||
let purchase_id = db::modules::record_module_purchase(
|
||||
&state.db,
|
||||
license_id,
|
||||
req.module_id,
|
||||
Some("STUB_TRANSACTION"), // TODO: Replace with real PayPal transaction ID
|
||||
Some(module.price_usd),
|
||||
).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Module purchase recorded: license={}, module={}, purchase_id={}",
|
||||
license_id, req.module_id, purchase_id
|
||||
);
|
||||
|
||||
Ok(Json(PurchaseResponse {
|
||||
success: true,
|
||||
purchase_id,
|
||||
message: "Module purchased successfully (payment stub)".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/modules/install
|
||||
/// Trigger module installation (calls panel adapter or companion agent).
|
||||
async fn install_module(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<InstallRequest>,
|
||||
) -> ApiResult<Json<InstallResponse>> {
|
||||
let license_id = auth.license_id.ok_or_else(|| {
|
||||
ApiError::BadRequest("No license associated with this user".to_string())
|
||||
})?;
|
||||
|
||||
// Verify ownership
|
||||
let is_purchased = db::modules::is_module_purchased(&state.db, license_id, req.module_id).await?;
|
||||
if !is_purchased {
|
||||
return Err(ApiError::Forbidden);
|
||||
}
|
||||
|
||||
// Verify module exists and get plugin file URL
|
||||
let module = db::modules::get_module_by_id(&state.db, req.module_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("Module not found".to_string()))?;
|
||||
|
||||
// Ensure NATS is available
|
||||
let nats_client = state
|
||||
.nats
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("NATS not available".to_string()))?;
|
||||
|
||||
let nats_bridge = Arc::new(NatsBridge::new(nats_client.clone()));
|
||||
let installer = ModuleInstaller::new(
|
||||
state.db.clone(),
|
||||
nats_bridge,
|
||||
state.config.encryption_key.clone(),
|
||||
);
|
||||
|
||||
// Set status to 'installing' immediately
|
||||
db::modules::update_installation_status(
|
||||
&state.db,
|
||||
license_id,
|
||||
req.module_id,
|
||||
"installing",
|
||||
None,
|
||||
).await?;
|
||||
|
||||
// Spawn background task for installation
|
||||
let module_name = module.name.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = installer.install_module(license_id, req.module_id).await {
|
||||
tracing::error!(
|
||||
"Module installation failed for license {} module {}: {}",
|
||||
license_id,
|
||||
req.module_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
"Module installation triggered: license={}, module={}",
|
||||
license_id, req.module_id
|
||||
);
|
||||
|
||||
Ok(Json(InstallResponse {
|
||||
success: true,
|
||||
message: format!("Installation of '{}' started. Check status for progress.", module_name),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/modules/:module_id/installation-status
|
||||
/// Get installation status for a specific module.
|
||||
async fn get_installation_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: AuthUser,
|
||||
Path(module_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<InstallationStatusResponse>> {
|
||||
let license_id = auth.license_id.ok_or_else(|| {
|
||||
ApiError::BadRequest("No license associated with this user".to_string())
|
||||
})?;
|
||||
|
||||
let installation = db::modules::get_module_installation_status(&state.db, license_id, module_id).await?;
|
||||
|
||||
Ok(Json(InstallationStatusResponse {
|
||||
module_id,
|
||||
status: installation.as_ref().map(|i| i.status.clone()),
|
||||
installed_at: installation.as_ref().and_then(|i| i.installed_at),
|
||||
error_message: installation.and_then(|i| i.error_message),
|
||||
}))
|
||||
}
|
||||
|
||||
// ===== Request/Response Types =====
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PurchaseRequest {
|
||||
module_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PurchaseResponse {
|
||||
success: bool,
|
||||
purchase_id: Uuid,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InstallRequest {
|
||||
module_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InstallResponse {
|
||||
success: bool,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CatalogResponse {
|
||||
modules: Vec<ModuleWithOwnership>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MyModulesResponse {
|
||||
modules: Vec<PurchasedModule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InstallationStatusResponse {
|
||||
module_id: Uuid,
|
||||
status: Option<String>,
|
||||
installed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
error_message: Option<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user