//! License key management and public activation/validation API. //! //! See also: `/docs/developer/license-keys` use axum::{ Form, Json, extract::{Path, State}, http::{StatusCode, header::HeaderMap}, response::{Html, IntoResponse, Response}, }; use chrono::{DateTime, Datelike, Utc}; use serde::{Deserialize, Serialize}; use crate::config::Config; use sqlx::PgPool; use crate::{ auth::AuthUser, db::{self, ItemId, KeyCode, LicenseKeyId}, error::{AppError, Result, ResultExt}, helpers::{self, hx_toast, is_htmx_request}, templates::{ItemLicenseKeysTemplate, SaveStatusTemplate}, types::LicenseKeyRow, types::ListResponse, validation, }; use jsonwebtoken::{EncodingKey, Header, encode}; use super::verify_item_ownership; /// JSON response for key validation/activation. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct ValidateKeyResponse { valid: bool, #[serde(skip_serializing_if = "Option::is_none")] activated: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] license: Option, } /// License details within a validation response. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct ValidateKeyLicense { #[schema(value_type = String)] item_id: ItemId, max_activations: Option, activation_count: i32, #[schema(value_type = String)] created_at: DateTime, } /// JSON response for key deactivation. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct DeactivateKeyResponse { success: bool, message: &'static str, } /// JSON response for key status check. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct KeyStatusResponse { valid: bool, #[serde(skip_serializing_if = "Option::is_none")] error: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] license: Option, } /// License details within a status response. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct KeyStatusLicense { #[schema(value_type = String)] item_id: ItemId, max_activations: Option, activation_count: i32, remaining_activations: Option, #[schema(value_type = String)] created_at: DateTime, } /// JSON response for key generation and listing. #[derive(Debug, Serialize)] struct GenerateKeyResponse { id: LicenseKeyId, key_code: KeyCode, max_activations: Option, created_at: DateTime, } // License Key API, Public (no auth, rate-limited) /// JSON input for validating/activating a license key. #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct ValidateKeyRequest { #[schema(value_type = String)] pub key: KeyCode, pub machine_id: String, pub label: Option, } /// Validate a license key and optionally activate it on a machine. #[utoipa::path( post, path = "/api/v1/keys/validate", tag = "License Keys", request_body = ValidateKeyRequest, responses( (status = 200, description = "Validation result", body = ValidateKeyResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::validate_key")] pub(super) async fn validate_key( State(db): State, Json(req): Json, ) -> Result { // key is auto-validated by KeyCode deserialization validation::validate_machine_id(&req.machine_id)?; if let Some(ref label) = req.label { validation::validate_activation_label(label)?; } // Look up key let Some(key) = db::license_keys::get_license_key_by_code(&db, &req.key).await? else { return Ok(Json(ValidateKeyResponse { valid: false, activated: None, error: Some("invalid_key"), license: None, })); }; // Check revoked if key.revoked_at.is_some() { return Ok(Json(ValidateKeyResponse { valid: false, activated: None, error: Some("key_revoked"), license: None, })); } // Check if already activated on this machine (fast path, no lock needed) if let Some(activation) = db::license_keys::get_activation(&db, key.id, &req.machine_id).await? && activation.is_active { db::license_keys::touch_activation(&db, activation.id).await?; // Report the authoritative denormalized count, not the pre-lookup read, // so a concurrent activation on another machine isn't reflected stale. let activation_count = db::license_keys::get_activation_count(&db, key.id).await?; return Ok(Json(ValidateKeyResponse { valid: true, activated: None, error: None, license: Some(ValidateKeyLicense { item_id: key.item_id, max_activations: key.max_activations, activation_count, created_at: key.created_at, }), })); } // Atomically check limit and create activation (serialized via FOR UPDATE) let activation = db::license_keys::try_create_activation(&db, key.id, &req.machine_id, req.label.as_deref()) .await?; if activation.is_none() { return Ok(Json(ValidateKeyResponse { valid: false, activated: None, error: Some("activation_limit_reached"), license: None, })); } // try_create_activation recomputed the denormalized count under the row // lock; read it back rather than guessing `+ 1` (which is wrong if another // machine activated concurrently). let activation_count = db::license_keys::get_activation_count(&db, key.id).await?; Ok(Json(ValidateKeyResponse { valid: true, activated: Some(true), error: None, license: Some(ValidateKeyLicense { item_id: key.item_id, max_activations: key.max_activations, activation_count, created_at: key.created_at, }), })) } /// JSON input for deactivating a machine. #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct DeactivateKeyRequest { #[schema(value_type = String)] pub key: KeyCode, pub machine_id: String, } /// Release an activation slot (user uninstalls). #[utoipa::path( post, path = "/api/v1/keys/deactivate", tag = "License Keys", request_body = DeactivateKeyRequest, responses( (status = 200, description = "Deactivation result", body = DeactivateKeyResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::deactivate_key")] pub(super) async fn deactivate_key( State(db): State, Json(req): Json, ) -> Result { validation::validate_machine_id(&req.machine_id)?; let Some(key) = db::license_keys::get_license_key_by_code(&db, &req.key).await? else { return Ok(Json(DeactivateKeyResponse { success: false, message: "Invalid key", })); }; let deactivated = db::license_keys::deactivate_machine(&db, key.id, &req.machine_id).await?; Ok(Json(DeactivateKeyResponse { success: deactivated, message: if deactivated { "Machine deactivated" } else { "No active activation found" }, })) } /// Compute the status response for a key code. Shared by the GET-path form and /// the POST-body form so they can't drift. async fn resolve_key_status(db: &PgPool, key_code: &KeyCode) -> Result { let Some(key) = db::license_keys::get_license_key_by_code(db, key_code).await? else { return Ok(KeyStatusResponse { valid: false, error: Some("invalid_key"), license: None, }); }; if key.revoked_at.is_some() { return Ok(KeyStatusResponse { valid: false, error: Some("key_revoked"), license: None, }); } // Clamp at 0: an admin can lower max_activations below the current activation // count, which would otherwise surface a negative remaining count publicly. let remaining = key .max_activations .map(|max| (max - key.activation_count).max(0)); Ok(KeyStatusResponse { valid: true, error: None, license: Some(KeyStatusLicense { item_id: key.item_id, max_activations: key.max_activations, activation_count: key.activation_count, remaining_activations: remaining, created_at: key.created_at, }), }) } /// Request body for the POST status check. #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct KeyStatusRequest { #[schema(value_type = String)] pub key: KeyCode, } /// Quick validity check without activating, key in the POST body. /// /// Prefer this over the GET `/keys/{key_code}/status` form: the key code is a /// purchase-proof secret, and putting it in the URL path leaks it into access /// and proxy logs. This matches the other license endpoints (validate, /// deactivate, verify), which all carry the key in the body. #[utoipa::path( post, path = "/api/v1/keys/status", tag = "License Keys", request_body = KeyStatusRequest, responses( (status = 200, description = "Key status", body = KeyStatusResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::key_status_post")] pub(super) async fn key_status_post( State(db): State, Json(req): Json, ) -> Result { Ok(Json(resolve_key_status(&db, &req.key).await?)) } /// Quick validity check without activating. /// /// DEPRECATED: the key code rides in the URL path, which leaks this /// purchase-proof secret into access/proxy logs. Use `POST /api/v1/keys/status` /// (key in the body) instead. Kept for backward compatibility with SDK /// consumers that predate the POST form. #[utoipa::path( get, path = "/api/v1/keys/{key_code}/status", tag = "License Keys", params(("key_code" = String, Path, description = "The license key code")), responses( (status = 200, description = "Key status (DEPRECATED. Prefer POST /api/v1/keys/status)", body = KeyStatusResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::key_status")] pub(super) async fn key_status( State(db): State, Path(key_code): Path, ) -> Result { let key_code = KeyCode::new(&key_code)?; Ok(Json(resolve_key_status(&db, &key_code).await?)) } // License Key API, Creator management (auth required) /// Form input for updating license key settings on an item. #[derive(Debug, Deserialize)] pub(crate) struct UpdateLicenseSettingsForm { pub enable_license_keys: Option, pub default_max_activations: Option, pub license_preset: Option, pub custom_license_text: Option, } /// Toggle license keys and set default activation limit for an item. #[tracing::instrument(skip_all, name = "license_keys::update_license_settings")] pub(super) async fn update_license_settings( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, Form(req): Form, ) -> Result { user.check_not_suspended()?; verify_item_ownership(&db, id, user.id).await?; let enable = req.enable_license_keys.is_some(); db::items::update_item_license_settings(&db, id, user.id, enable, req.default_max_activations) .await?; // License text preset let preset_str = req.license_preset.as_deref().filter(|s| !s.is_empty()); if let Some(preset_key) = preset_str { // Validate preset key use crate::license_templates::LicensePreset; let preset: LicensePreset = preset_key .parse() .map_err(|_| crate::error::AppError::validation("Invalid license preset"))?; let custom_text = if preset == LicensePreset::Custom { let text = req.custom_license_text.as_deref().unwrap_or("").trim(); if text.is_empty() { return Err(crate::error::AppError::validation( "Custom license text is required when using Custom preset", )); } Some(text) } else { None }; db::items::update_item_license_text(&db, id, user.id, Some(preset_key), custom_text) .await?; } else { // Clear license db::items::update_item_license_text(&db, id, user.id, None, None).await?; } if is_htmx_request(&headers) { return Ok(Html( SaveStatusTemplate { success: true, message: "License settings saved".to_string(), } .render_string()?, ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Form input for generating a license key. #[derive(Debug, Deserialize)] pub(crate) struct GenerateKeyForm { pub max_activations: Option, } /// Generate a new license key for an item (creator dashboard). #[tracing::instrument(skip_all, name = "license_keys::generate_key")] pub(super) async fn generate_key( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(item_id): Path, Form(req): Form, ) -> Result { user.check_not_suspended()?; let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?; if !item.enable_license_keys { return Err(AppError::BadRequest( "License keys are not enabled for this item".to_string(), )); } let key_code = helpers::generate_key_code(); let max_activations = req.max_activations.or(item.default_max_activations); // Cap at 1000 manually-generated keys per item, enforced atomically under an // item-row lock so concurrent generates can't race past it (fuzz 2026-07-06 C6-1). let Some(key) = db::license_keys::create_manual_key_capped( &db, item_id, user.id, &key_code, max_activations, 1000, ) .await? else { return Err(AppError::BadRequest( "Maximum of 1000 license keys per item reached".to_string(), )); }; if is_htmx_request(&headers) { let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?; return Ok(ItemLicenseKeysTemplate { license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(), } .into_response()); } Ok(Json(GenerateKeyResponse { id: key.id, key_code: key.key_code, max_activations: key.max_activations, created_at: key.created_at, }) .into_response()) } /// List all license keys for an item (creator dashboard). #[tracing::instrument(skip_all, name = "license_keys::list_keys")] pub(super) async fn list_keys( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { verify_item_ownership(&db, item_id, user.id).await?; let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?; if is_htmx_request(&headers) { return Ok(ItemLicenseKeysTemplate { license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(), } .into_response()); } let data: Vec = keys .into_iter() .map(|k| GenerateKeyResponse { id: k.id, key_code: k.key_code, max_activations: k.max_activations, created_at: k.created_at, }) .collect(); Ok(Json(ListResponse { data }).into_response()) } /// Revoke a license key (creator dashboard). #[tracing::instrument(skip_all, name = "license_keys::revoke_key")] pub(super) async fn revoke_key( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(key_id): Path, ) -> Result { user.check_not_suspended()?; let key = db::license_keys::get_license_key_by_id_unchecked(&db, key_id) .await? .ok_or(AppError::NotFound)?; verify_item_ownership(&db, key.item_id, user.id).await?; db::license_keys::revoke_license_key(&db, key_id).await?; if is_htmx_request(&headers) { let keys = db::license_keys::get_license_keys_by_item(&db, key.item_id).await?; return Ok(( [("HX-Trigger", hx_toast("License key revoked", "success"))], ItemLicenseKeysTemplate { license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(), }, ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } // License Verification API, phone-home activation binding /// JWT claims for offline license verification. #[derive(Debug, Serialize, Deserialize)] struct LicenseVerifyClaims { /// License key ID sub: String, /// Machine fingerprint machine: String, /// Item ID item: String, /// Issued at (Unix timestamp) iat: i64, /// Expiration (Unix timestamp); 7-day offline grace period exp: i64, } /// JSON response for license verification. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct LicenseVerifyResponse { valid: bool, #[serde(skip_serializing_if = "Option::is_none")] token: Option, #[serde(skip_serializing_if = "Option::is_none")] expires_in: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option<&'static str>, } /// JSON input for license verification (phone-home). #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct LicenseVerifyRequest { #[schema(value_type = String)] pub key: KeyCode, pub machine_fingerprint: String, } /// 7-day offline grace period for signed JWTs. const LICENSE_VERIFY_EXPIRY_SECS: i64 = 7 * 24 * 3600; /// Verify a license key and bind it to a machine fingerprint. /// /// If the project has `license_verification_enabled`, validates the key, /// checks/creates an activation (using machine_fingerprint as machine_id), /// and returns a signed JWT for offline verification (valid 7 days). #[utoipa::path( post, path = "/api/v1/license/verify", tag = "License Keys", request_body = LicenseVerifyRequest, responses( (status = 200, description = "Verification result with optional offline JWT", body = LicenseVerifyResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::license_verify")] pub(super) async fn license_verify( State(db): State, State(config): State, Json(req): Json, ) -> Result { validation::validate_machine_id(&req.machine_fingerprint)?; let Some(key) = db::license_keys::get_license_key_by_code(&db, &req.key).await? else { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("invalid_key"), })); }; if key.revoked_at.is_some() { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("key_revoked"), })); } // Check if the project has license verification enabled let item = db::items::get_item_by_id(&db, key.item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&db, item.project_id) .await? .ok_or(AppError::NotFound)?; if !project.license_verification_enabled { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("verification_not_enabled"), })); } // Try to activate (re-activation on same machine is always OK) if let Some(activation) = db::license_keys::get_activation(&db, key.id, &req.machine_fingerprint).await? { if activation.is_active { db::license_keys::touch_activation(&db, activation.id).await?; } else { // Re-activate a previously deactivated machine let reactivated = db::license_keys::try_create_activation( &db, key.id, &req.machine_fingerprint, None, ) .await?; if reactivated.is_none() { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("activation_limit_reached"), })); } } } else { // New activation let activation = db::license_keys::try_create_activation(&db, key.id, &req.machine_fingerprint, None) .await?; if activation.is_none() { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("activation_limit_reached"), })); } } // Issue a signed JWT for offline grace period let now = chrono::Utc::now().timestamp(); let claims = LicenseVerifyClaims { sub: key.id.to_string(), machine: req.machine_fingerprint, item: key.item_id.to_string(), iat: now, exp: now + LICENSE_VERIFY_EXPIRY_SECS, }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(config.signing_secret.as_bytes()), ) .context("jwt encode")?; Ok(Json(LicenseVerifyResponse { valid: true, token: Some(token), expires_in: Some(LICENSE_VERIFY_EXPIRY_SECS), error: None, })) } /// JSON input for license deactivation (phone-home). #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct LicenseDeactivateRequest { #[schema(value_type = String)] pub key: KeyCode, pub machine_fingerprint: String, } /// Deactivate a license on a specific machine (free up a slot). #[utoipa::path( post, path = "/api/v1/license/deactivate", tag = "License Keys", request_body = LicenseDeactivateRequest, responses( (status = 200, description = "Deactivation result", body = DeactivateKeyResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::license_deactivate")] pub(super) async fn license_deactivate( State(db): State, Json(req): Json, ) -> Result { validation::validate_machine_id(&req.machine_fingerprint)?; let Some(key) = db::license_keys::get_license_key_by_code(&db, &req.key).await? else { return Ok(Json(DeactivateKeyResponse { success: false, message: "Invalid key", })); }; let deactivated = db::license_keys::deactivate_machine(&db, key.id, &req.machine_fingerprint).await?; Ok(Json(DeactivateKeyResponse { success: deactivated, message: if deactivated { "Machine deactivated" } else { "No active activation found" }, })) } // License Text, public endpoint /// Serve rendered license text for an item as plain text. #[utoipa::path( get, path = "/api/v1/items/{item_id}/license.txt", tag = "License Keys", params(("item_id" = String, Path, description = "The item ID")), responses( (status = 200, description = "License text", content_type = "text/plain"), (status = 404, description = "Item not found or no license configured"), ), )] #[tracing::instrument(skip_all, name = "license_keys::license_text")] pub(super) async fn license_text( State(db): State, Path(item_id): Path, ) -> Result { use crate::license_templates::{LicensePreset, render_license_text}; let item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; let preset_str = item.license_preset.as_deref().ok_or(AppError::NotFound)?; let preset: LicensePreset = preset_str.parse().map_err(|_| AppError::NotFound)?; // Get owner display name let project = db::projects::get_project_by_id(&db, item.project_id) .await? .ok_or(AppError::NotFound)?; let user = db::users::get_user_by_id(&db, project.user_id) .await? .ok_or(AppError::NotFound)?; // Visibility gate: this endpoint is unauthenticated, so a draft/deleted item // (or a sandbox account's) must not leak its license text or owner name to // anyone who guesses the item UUID (Run 21 security). if !item.is_public || item.deleted_at.is_some() || user.is_sandbox { return Err(AppError::NotFound); } let owner = user.display_name.as_deref().unwrap_or(&user.username); let year = chrono::Utc::now().year(); let text = render_license_text(preset, owner, year, item.custom_license_text.as_deref()); Ok(( StatusCode::OK, [( axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8", )], text, ) .into_response()) }