feat: Phase 4 module auto-installation + Phase 5 webstore backend
All checks were successful
Test Asgard Runner / test (push) Successful in 2s

Phase 4 Contributions (Agent Golf):
- Module auto-installation service (module_installer.rs)
- NATS subject pattern for module installation commands
- Companion agent contract documentation
- API endpoint: POST /api/modules/install

Phase 5 XO Direct Touch:
- Webstore subscription API (PayPal recurring billing)
  * POST /api/webstore/subscription/create
  * GET /api/webstore/subscription
  * POST /api/webstore/subscription/cancel
  * POST /api/webstore/subscription/webhook
- Store configuration API (CRUD for store settings)
  * GET /api/webstore/config
  * PUT /api/webstore/config
- Store category/item management APIs (multi-tenant CRUD)
  * GET/POST/PUT/DELETE /api/webstore/categories
  * GET/POST/PUT/DELETE /api/webstore/items
- Public store API (customer-facing, subdomain-scoped)
  * GET /api/public-store/:subdomain
  * GET /api/public-store/:subdomain/items
  * POST /api/public-store/:subdomain/purchase
  * POST /api/public-store/:subdomain/webhook
- Transaction history API
  * GET /api/webstore/transactions
- Delivery system (NATS command execution on purchase)
- Migrations: payment_orders, webstore_subscriptions, store_config, store_items, store_transactions

Security:
- JWT auth + license_id scoping on admin endpoints
- Subdomain → license_id mapping on public endpoints
- Purchase limit enforcement
- Command injection prevention via placeholder replacement

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 14:53:38 -05:00
parent 18da1838c4
commit 6c2436dfc6
15 changed files with 2423 additions and 2 deletions

View File

@@ -0,0 +1,410 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::{
models::error::AppError,
services::payment_processor::PayPalProcessor,
AppState,
};
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/:subdomain", get(get_store_info))
.route("/:subdomain/items", get(get_store_items))
.route("/:subdomain/purchase", post(create_purchase_order))
.route("/:subdomain/webhook", post(handle_purchase_webhook))
}
#[derive(Serialize)]
struct PublicStoreInfo {
store_name: String,
description: Option<String>,
currency: String,
enabled: bool,
}
/// Get public store information by subdomain
async fn get_store_info(
State(state): State<Arc<AppState>>,
Path(subdomain): Path<String>,
) -> Result<impl IntoResponse, AppError> {
// Get license_id from subdomain
let license = sqlx::query!("SELECT id FROM licenses WHERE subdomain = $1", subdomain)
.fetch_optional(&state.db)
.await?;
let license_id = license
.ok_or_else(|| AppError::NotFound("Store not found".to_string()))?
.id;
// Get store config
let config = sqlx::query_as!(
PublicStoreInfo,
"SELECT store_name, description, currency, enabled
FROM store_config
WHERE license_id = $1",
license_id
)
.fetch_optional(&state.db)
.await?;
if let Some(config) = config {
if !config.enabled {
return Err(AppError::NotFound("Store is currently disabled".to_string()));
}
Ok(Json(config))
} else {
Err(AppError::NotFound("Store not configured".to_string()))
}
}
#[derive(Serialize)]
struct PublicStoreItem {
id: Uuid,
category_name: Option<String>,
name: String,
description: Option<String>,
price: rust_decimal::Decimal,
image_url: Option<String>,
item_type: String,
limit_per_player: Option<i32>,
}
/// Get all items available in the public store
async fn get_store_items(
State(state): State<Arc<AppState>>,
Path(subdomain): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let license = sqlx::query!("SELECT id FROM licenses WHERE subdomain = $1", subdomain)
.fetch_optional(&state.db)
.await?;
let license_id = license
.ok_or_else(|| AppError::NotFound("Store not found".to_string()))?
.id;
// Check if store is enabled
let enabled = sqlx::query_scalar!("SELECT enabled FROM store_config WHERE license_id = $1", license_id)
.fetch_optional(&state.db)
.await?
.unwrap_or(false);
if !enabled {
return Err(AppError::NotFound("Store is currently disabled".to_string()));
}
type Row = (
Uuid,
Option<String>,
String,
Option<String>,
rust_decimal::Decimal,
Option<String>,
String,
Option<i32>,
);
let rows: Vec<Row> = sqlx::query_as(
"SELECT
i.id,
c.name as category_name,
i.name,
i.description,
i.price,
i.image_url,
i.item_type,
i.limit_per_player
FROM store_items i
LEFT JOIN store_categories c ON c.id = i.category_id
WHERE i.license_id = $1 AND i.enabled = true
ORDER BY c.display_order, i.name",
)
.bind(license_id)
.fetch_all(&state.db)
.await?;
let items: Vec<PublicStoreItem> = rows
.into_iter()
.map(|row| PublicStoreItem {
id: row.0,
category_name: row.1,
name: row.2,
description: row.3,
price: row.4,
image_url: row.5,
item_type: row.6,
limit_per_player: row.7,
})
.collect();
Ok(Json(items))
}
#[derive(Deserialize)]
struct CreatePurchaseRequest {
item_id: Uuid,
steam_id: String,
player_name: String,
}
#[derive(Serialize)]
struct CreatePurchaseResponse {
order_id: String,
approval_url: String,
}
/// Create a PayPal order for a store item purchase
async fn create_purchase_order(
State(state): State<Arc<AppState>>,
Path(subdomain): Path<String>,
Json(req): Json<CreatePurchaseRequest>,
) -> Result<impl IntoResponse, AppError> {
let license = sqlx::query!("SELECT id FROM licenses WHERE subdomain = $1", subdomain)
.fetch_optional(&state.db)
.await?;
let license_id = license
.ok_or_else(|| AppError::NotFound("Store not found".to_string()))?
.id;
// Get store config and check if enabled
let store_config = sqlx::query!(
"SELECT paypal_client_id, paypal_client_secret, sandbox_mode, enabled
FROM store_config
WHERE license_id = $1",
license_id
)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Store not configured".to_string()))?;
if !store_config.enabled {
return Err(AppError::BadRequest("Store is currently disabled".to_string()));
}
// Get item details
let item = sqlx::query!(
"SELECT name, price FROM store_items WHERE id = $1 AND license_id = $2 AND enabled = true",
req.item_id,
license_id
)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Item not found or disabled".to_string()))?;
// Check purchase limit if set
if let Some(limit) = sqlx::query_scalar!(
"SELECT limit_per_player FROM store_items WHERE id = $1",
req.item_id
)
.fetch_one(&state.db)
.await?
{
let purchase_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM store_transactions
WHERE item_id = $1 AND steam_id = $2 AND status IN ('paid', 'delivered')",
req.item_id,
req.steam_id
)
.fetch_one(&state.db)
.await?
.unwrap_or(0);
if purchase_count >= limit as i64 {
return Err(AppError::BadRequest("Purchase limit reached for this item".to_string()));
}
}
// Create PayPal processor using store owner's credentials
let client_id = store_config.paypal_client_id.ok_or_else(|| {
AppError::Internal("Store PayPal credentials not configured".to_string())
})?;
let client_secret = store_config.paypal_client_secret.ok_or_else(|| {
AppError::Internal("Store PayPal credentials not configured".to_string())
})?;
// TODO: Decrypt client_secret using encryption service
let decrypted_secret = client_secret; // Placeholder - should decrypt
let processor = PayPalProcessor::new(
client_id.clone(),
decrypted_secret,
std::env::var("PAYPAL_WEBHOOK_ID").unwrap_or_default(),
store_config.sandbox_mode,
state.db.clone(),
);
// Create PayPal order
let price_f64: f64 = item.price.to_string().parse().unwrap_or(0.0);
let approval_url = processor
.create_order(&item.name, price_f64, Some(req.item_id))
.await?;
// Extract order_id from approval_url (it's in the query params)
let order_id = approval_url
.split("token=")
.nth(1)
.and_then(|s| s.split('&').next())
.unwrap_or_default()
.to_string();
// Store pending transaction
sqlx::query!(
"INSERT INTO store_transactions (license_id, item_id, steam_id, player_name, paypal_order_id, amount, currency, status)
VALUES ($1, $2, $3, $4, $5, $6, 'USD', 'pending')",
license_id,
req.item_id,
req.steam_id,
req.player_name,
order_id,
item.price
)
.execute(&state.db)
.await?;
Ok(Json(CreatePurchaseResponse {
order_id: order_id.clone(),
approval_url,
}))
}
/// Handle PayPal webhooks for store purchases
async fn handle_purchase_webhook(
State(state): State<Arc<AppState>>,
Path(subdomain): Path<String>,
Json(webhook): Json<serde_json::Value>,
) -> Result<impl IntoResponse, AppError> {
tracing::info!("Store purchase webhook for {}: {:?}", subdomain, webhook);
let license = sqlx::query!("SELECT id FROM licenses WHERE subdomain = $1", subdomain)
.fetch_optional(&state.db)
.await?;
let license_id = license
.ok_or_else(|| AppError::NotFound("Store not found".to_string()))?
.id;
// TODO: Verify webhook signature
// Parse event_type
let event_type = webhook
.get("event_type")
.and_then(|v| v.as_str())
.unwrap_or_default();
match event_type {
"PAYMENT.CAPTURE.COMPLETED" => {
handle_payment_completed(&state, license_id, webhook).await?;
}
"PAYMENT.CAPTURE.DENIED" | "PAYMENT.CAPTURE.REFUNDED" => {
handle_payment_failed(&state, license_id, webhook).await?;
}
_ => {
tracing::info!("Unhandled store webhook event: {}", event_type);
}
}
Ok(StatusCode::OK)
}
async fn handle_payment_completed(
state: &AppState,
license_id: Uuid,
webhook: serde_json::Value,
) -> Result<(), AppError> {
let order_id = webhook
.pointer("/resource/supplementary_data/related_ids/order_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("Missing order_id in webhook".to_string()))?;
// Update transaction status
let transaction = sqlx::query!(
"UPDATE store_transactions
SET status = 'paid', paypal_transaction_id = $1
WHERE paypal_order_id = $2 AND license_id = $3
RETURNING item_id, steam_id",
webhook.get("id").and_then(|v| v.as_str()),
order_id,
license_id
)
.fetch_optional(&state.db)
.await?;
if let Some(txn) = transaction {
// Get item delivery commands
if let Some(item_id) = txn.item_id {
let item = sqlx::query!(
"SELECT delivery_commands FROM store_items WHERE id = $1",
item_id
)
.fetch_one(&state.db)
.await?;
// Parse delivery commands and send via NATS
if let Some(commands_json) = item.delivery_commands {
let commands: Vec<String> = serde_json::from_value(commands_json)?;
// Replace {steam_id} placeholder in commands
let steam_id = txn.steam_id;
let replaced_commands: Vec<String> = commands
.iter()
.map(|cmd| cmd.replace("{steam_id}", &steam_id))
.collect();
// Send commands via NATS to game server
if let Some(ref nats) = state.nats {
for command in replaced_commands {
let subject = format!("corrosion.{}.cmd.console", license_id);
let payload = serde_json::json!({ "command": command });
nats.publish(subject, serde_json::to_vec(&payload)?.into())
.await
.map_err(|e| AppError::Internal(format!("NATS publish failed: {}", e)))?;
}
// Mark as delivered
sqlx::query!(
"UPDATE store_transactions SET delivered = true, delivered_at = NOW(), status = 'delivered' WHERE paypal_order_id = $1",
order_id
)
.execute(&state.db)
.await?;
tracing::info!("Delivered store purchase: order_id={}", order_id);
}
}
}
}
Ok(())
}
async fn handle_payment_failed(
state: &AppState,
license_id: Uuid,
webhook: serde_json::Value,
) -> Result<(), AppError> {
let order_id = webhook
.pointer("/resource/supplementary_data/related_ids/order_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("Missing order_id in webhook".to_string()))?;
sqlx::query!(
"UPDATE store_transactions SET status = 'failed' WHERE paypal_order_id = $1 AND license_id = $2",
order_id,
license_id
)
.execute(&state.db)
.await?;
tracing::warn!("Store payment failed: order_id={}", order_id);
Ok(())
}