//! License key management: CRUD, activation tracking, and revocation. use sqlx::PgPool; use super::models::*; use super::validated_types::KeyCode; use super::{ItemId, LicenseActivationId, LicenseKeyId, TransactionId, UserId}; use crate::error::Result; /// Create a new license key for an item. /// /// Retries once on a 23505 unique-violation with a freshly-generated code. /// A real collision out of the wordlist generator is vanishingly rare (the /// six-word space gives ~6B coin-flip headroom), but the alternative is /// surfacing a 500 to whatever flow is creating the key. #[tracing::instrument(skip_all)] pub async fn create_license_key( pool: &PgPool, item_id: ItemId, owner_id: UserId, transaction_id: Option, key_code: &KeyCode, max_activations: Option, ) -> Result { const SQL: &str = r#" INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations) VALUES ($1, $2, $3, $4, $5) RETURNING * "#; let first = sqlx::query_as::<_, DbLicenseKey>(SQL) .bind(item_id) .bind(owner_id) .bind(transaction_id) .bind(key_code) .bind(max_activations) .fetch_one(pool) .await; match first { Ok(key) => Ok(key), Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => { let retry_code = crate::helpers::generate_key_code(); tracing::warn!(item_id = %item_id, "license key 23505 collision; retrying once"); let key = sqlx::query_as::<_, DbLicenseKey>(SQL) .bind(item_id) .bind(owner_id) .bind(transaction_id) .bind(&retry_code) .bind(max_activations) .fetch_one(pool) .await?; Ok(key) } Err(e) => Err(e.into()), } } /// Look up a license key by its code. #[tracing::instrument(skip_all)] pub async fn get_license_key_by_code(pool: &PgPool, key_code: &KeyCode) -> Result> { let key = sqlx::query_as::<_, DbLicenseKey>( "SELECT * FROM license_keys WHERE key_code = $1", ) .bind(key_code) .fetch_optional(pool) .await?; Ok(key) } /// Get a license key by ID. #[tracing::instrument(skip_all)] pub async fn get_license_key_by_id(pool: &PgPool, id: LicenseKeyId) -> Result> { let key = sqlx::query_as::<_, DbLicenseKey>( "SELECT * FROM license_keys WHERE id = $1", ) .bind(id) .fetch_optional(pool) .await?; Ok(key) } /// Count license keys for an item. #[tracing::instrument(skip_all)] pub async fn count_keys_by_item(pool: &PgPool, item_id: ItemId) -> Result { let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM license_keys WHERE item_id = $1", ) .bind(item_id) .fetch_one(pool) .await?; Ok(count) } /// List all license keys for an item, newest first. /// /// Hard-caps at 500 rows to bound memory and response size for the creator /// dashboard list view. Items with more than 500 keys are uncommon; /// future work could add cursor-based pagination if needed. #[tracing::instrument(skip_all)] pub async fn get_license_keys_by_item(pool: &PgPool, item_id: ItemId) -> Result> { let keys = sqlx::query_as::<_, DbLicenseKey>( "SELECT * FROM license_keys WHERE item_id = $1 ORDER BY created_at DESC LIMIT 500", ) .bind(item_id) .fetch_all(pool) .await?; Ok(keys) } /// Batch-load license keys for multiple items, grouped by item_id. #[tracing::instrument(skip_all)] pub async fn get_license_keys_by_items( pool: &PgPool, item_ids: &[ItemId], ) -> Result>> { let keys = sqlx::query_as::<_, DbLicenseKey>( "SELECT * FROM license_keys WHERE item_id = ANY($1) ORDER BY item_id, created_at DESC", ) .bind(item_ids) .fetch_all(pool) .await?; let mut map: std::collections::HashMap> = std::collections::HashMap::new(); for k in keys { map.entry(k.item_id).or_default().push(k); } Ok(map) } /// Find an existing activation for a key + machine combo. #[tracing::instrument(skip_all)] pub async fn get_activation( pool: &PgPool, license_key_id: LicenseKeyId, machine_id: &str, ) -> Result> { let activation = sqlx::query_as::<_, DbLicenseActivation>( "SELECT * FROM license_activations WHERE license_key_id = $1 AND machine_id = $2", ) .bind(license_key_id) .bind(machine_id) .fetch_optional(pool) .await?; Ok(activation) } /// Update the last_validated_at timestamp for an existing activation. #[tracing::instrument(skip_all)] pub async fn touch_activation(pool: &PgPool, activation_id: LicenseActivationId) -> Result<()> { sqlx::query( "UPDATE license_activations SET last_validated_at = NOW() WHERE id = $1", ) .bind(activation_id) .execute(pool) .await?; Ok(()) } /// Activate a license key on a machine, atomically enforcing max_activations. /// /// Uses a transaction with `FOR UPDATE` to serialize concurrent activations /// for the same key. Re-activations (same machine_id) always succeed via /// upsert. New activations are rejected if the active count would exceed /// `max_activations`. /// /// Returns `None` if the activation limit has been reached. /// /// After the upsert, the denormalized `activation_count` on `license_keys` /// is refreshed with a full COUNT rather than an increment; this avoids /// drift if a crash leaves the count out of sync. #[tracing::instrument(skip_all)] pub async fn try_create_activation( pool: &PgPool, license_key_id: LicenseKeyId, machine_id: &str, label: Option<&str>, max_activations: Option, ) -> Result> { let mut tx = pool.begin().await?; // Lock the license key row to serialize concurrent activations sqlx::query("SELECT 1 FROM license_keys WHERE id = $1 FOR UPDATE") .bind(license_key_id) .fetch_one(&mut *tx) .await?; // Check if this machine already has an activation (re-activation is always OK) let existing: Option = sqlx::query_as( "SELECT * FROM license_activations WHERE license_key_id = $1 AND machine_id = $2", ) .bind(license_key_id) .bind(machine_id) .fetch_optional(&mut *tx) .await?; // For truly new activations, enforce the limit if existing.is_none() && let Some(max) = max_activations { let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM license_activations WHERE license_key_id = $1 AND is_active = true", ) .bind(license_key_id) .fetch_one(&mut *tx) .await?; if count >= max as i64 { tx.rollback().await?; return Ok(None); } } // Upsert: if same machine_id re-activates, reactivate it let activation = sqlx::query_as::<_, DbLicenseActivation>( r#" INSERT INTO license_activations (license_key_id, machine_id, label) VALUES ($1, $2, $3) ON CONFLICT (license_key_id, machine_id) DO UPDATE SET is_active = true, last_validated_at = NOW(), label = COALESCE(EXCLUDED.label, license_activations.label) RETURNING * "#, ) .bind(license_key_id) .bind(machine_id) .bind(label) .fetch_one(&mut *tx) .await?; // Recount active activations to keep denormalized count accurate sqlx::query( r#" UPDATE license_keys SET activation_count = ( SELECT COUNT(*) FROM license_activations WHERE license_key_id = $1 AND is_active = true ) WHERE id = $1 "#, ) .bind(license_key_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Some(activation)) } /// Deactivate a machine and update the key's activation_count. /// /// Only recounts if a row was actually deactivated (`rows_affected > 0`), /// avoiding a wasted query when the machine wasn't active. Uses the same /// full-recount strategy as [`try_create_activation`] for consistency. #[tracing::instrument(skip_all)] pub async fn deactivate_machine( pool: &PgPool, license_key_id: LicenseKeyId, machine_id: &str, ) -> Result { let mut tx = pool.begin().await?; let result = sqlx::query( r#" UPDATE license_activations SET is_active = false WHERE license_key_id = $1 AND machine_id = $2 AND is_active = true "#, ) .bind(license_key_id) .bind(machine_id) .execute(&mut *tx) .await?; if result.rows_affected() > 0 { // Recount active activations sqlx::query( r#" UPDATE license_keys SET activation_count = ( SELECT COUNT(*) FROM license_activations WHERE license_key_id = $1 AND is_active = true ) WHERE id = $1 "#, ) .bind(license_key_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(true) } else { tx.commit().await?; Ok(false) } } /// Revoke a license key and deactivate all its activations. /// /// Wrapped in a transaction so the key revocation and activation /// deactivation are atomic; a crash between the two statements /// cannot leave the key revoked with activations still active. #[tracing::instrument(skip_all)] pub async fn revoke_license_key(pool: &PgPool, key_id: LicenseKeyId) -> Result<()> { let mut tx = pool.begin().await?; sqlx::query( r#" UPDATE license_keys SET revoked_at = NOW() WHERE id = $1 "#, ) .bind(key_id) .execute(&mut *tx) .await?; sqlx::query( "UPDATE license_activations SET is_active = false WHERE license_key_id = $1", ) .bind(key_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) } /// Revoke all license keys for a given transaction and deactivate all activations. /// Called from the Stripe `charge.refunded` webhook handler. /// /// Two-step approach: bulk-revoke keys, then bulk-deactivate activations. /// Separate queries because `license_activations` is keyed by `license_key_id`, /// not `transaction_id`. #[tracing::instrument(skip_all)] pub async fn revoke_keys_by_transaction( conn: &mut sqlx::PgConnection, transaction_id: TransactionId, ) -> Result { // Get all key IDs for this transaction let key_ids: Vec = sqlx::query_scalar( "SELECT id FROM license_keys WHERE transaction_id = $1 AND revoked_at IS NULL", ) .bind(transaction_id) .fetch_all(&mut *conn) .await?; if key_ids.is_empty() { return Ok(0); } // Revoke the keys let result = sqlx::query( r#" UPDATE license_keys SET revoked_at = NOW() WHERE transaction_id = $1 AND revoked_at IS NULL "#, ) .bind(transaction_id) .execute(&mut *conn) .await?; // Deactivate all activations for those keys in a single query if !key_ids.is_empty() { sqlx::query( "UPDATE license_activations SET is_active = false WHERE license_key_id = ANY($1)", ) .bind(&key_ids) .execute(&mut *conn) .await?; } Ok(result.rows_affected()) }