talos/server/
client_api.rs

1//! Client API endpoints for license binding and validation.
2//!
3//! These endpoints are used by client applications to bind, release, and validate licenses.
4//! They do not require authentication but are rate-limited to prevent abuse.
5//!
6//! # Endpoints
7//!
8//! - `POST /api/v1/client/bind` - Bind a license to hardware
9//! - `POST /api/v1/client/release` - Release a license from hardware
10//! - `POST /api/v1/client/validate` - Validate a license
11//! - `POST /api/v1/client/validate-or-bind` - Validate or auto-bind a license
12//! - `POST /api/v1/client/heartbeat` - Send heartbeat ping
13//! - `POST /api/v1/client/validate-feature` - Validate a specific feature
14
15use axum::{
16    extract::State,
17    http::StatusCode,
18    response::{IntoResponse, Response},
19    Json,
20};
21use chrono::Utc;
22use serde::{Deserialize, Serialize};
23use tracing::{info, warn};
24
25#[cfg(feature = "openapi")]
26use utoipa::ToSchema;
27
28use crate::server::api_error::{ApiError, ErrorCode};
29use crate::server::database::{BindingAction, PerformedBy};
30use crate::server::handlers::AppState;
31use crate::server::logging::{log_license_binding_event, log_license_event, LicenseEvent};
32use crate::tiers::get_tier_config;
33
34/// Error codes for client API responses.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[cfg_attr(feature = "openapi", derive(ToSchema))]
37#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
38pub enum ClientErrorCode {
39    /// License key not found
40    LicenseNotFound,
41    /// License is already bound to different hardware
42    AlreadyBound,
43    /// License is not bound (for release operations)
44    NotBound,
45    /// Hardware ID doesn't match the bound hardware
46    HardwareMismatch,
47    /// License is expired
48    LicenseExpired,
49    /// License is revoked
50    LicenseRevoked,
51    /// License is suspended (may be in grace period)
52    LicenseSuspended,
53    /// License is blacklisted
54    LicenseBlacklisted,
55    /// License is not active
56    LicenseInactive,
57    /// Feature not included in license or tier
58    FeatureNotIncluded,
59    /// Feature restricted due to quota exceeded
60    QuotaExceeded,
61    /// Invalid request format
62    InvalidRequest,
63    /// Internal server error
64    InternalError,
65}
66
67/// Client API error response.
68#[derive(Debug, Serialize)]
69#[cfg_attr(feature = "openapi", derive(ToSchema))]
70pub struct ClientError {
71    pub success: bool,
72    pub error: ClientErrorCode,
73    pub message: String,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub bound_device: Option<String>,
76}
77
78impl ClientError {
79    pub fn new(code: ClientErrorCode, message: impl Into<String>) -> Self {
80        Self {
81            success: false,
82            error: code,
83            message: message.into(),
84            bound_device: None,
85        }
86    }
87
88    pub fn with_bound_device(mut self, device: Option<String>) -> Self {
89        self.bound_device = device;
90        self
91    }
92
93    pub fn status_code(&self) -> StatusCode {
94        match self.error {
95            ClientErrorCode::LicenseNotFound => StatusCode::NOT_FOUND,
96            ClientErrorCode::AlreadyBound => StatusCode::CONFLICT,
97            ClientErrorCode::NotBound => StatusCode::CONFLICT,
98            ClientErrorCode::HardwareMismatch => StatusCode::FORBIDDEN,
99            ClientErrorCode::LicenseExpired => StatusCode::FORBIDDEN,
100            ClientErrorCode::LicenseRevoked => StatusCode::FORBIDDEN,
101            ClientErrorCode::LicenseSuspended => StatusCode::FORBIDDEN,
102            ClientErrorCode::LicenseBlacklisted => StatusCode::FORBIDDEN,
103            ClientErrorCode::LicenseInactive => StatusCode::FORBIDDEN,
104            ClientErrorCode::FeatureNotIncluded => StatusCode::FORBIDDEN,
105            ClientErrorCode::QuotaExceeded => StatusCode::FORBIDDEN,
106            ClientErrorCode::InvalidRequest => StatusCode::BAD_REQUEST,
107            ClientErrorCode::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
108        }
109    }
110}
111
112impl IntoResponse for ClientError {
113    fn into_response(self) -> Response {
114        let api_error: ApiError = self.into();
115        api_error.into_response()
116    }
117}
118
119impl From<ClientErrorCode> for ErrorCode {
120    fn from(code: ClientErrorCode) -> Self {
121        match code {
122            ClientErrorCode::LicenseNotFound => ErrorCode::LicenseNotFound,
123            ClientErrorCode::AlreadyBound => ErrorCode::AlreadyBound,
124            ClientErrorCode::NotBound => ErrorCode::NotBound,
125            ClientErrorCode::HardwareMismatch => ErrorCode::HardwareMismatch,
126            ClientErrorCode::LicenseExpired => ErrorCode::LicenseExpired,
127            ClientErrorCode::LicenseRevoked => ErrorCode::LicenseRevoked,
128            ClientErrorCode::LicenseSuspended => ErrorCode::LicenseSuspended,
129            ClientErrorCode::LicenseBlacklisted => ErrorCode::LicenseBlacklisted,
130            ClientErrorCode::LicenseInactive => ErrorCode::LicenseInactive,
131            ClientErrorCode::FeatureNotIncluded => ErrorCode::FeatureNotIncluded,
132            ClientErrorCode::QuotaExceeded => ErrorCode::QuotaExceeded,
133            ClientErrorCode::InvalidRequest => ErrorCode::InvalidRequest,
134            ClientErrorCode::InternalError => ErrorCode::InternalError,
135        }
136    }
137}
138
139impl From<ClientError> for ApiError {
140    fn from(err: ClientError) -> Self {
141        let code: ErrorCode = err.error.into();
142        if let Some(device) = err.bound_device {
143            ApiError::with_details(
144                code,
145                err.message,
146                serde_json::json!({ "bound_device": device }),
147            )
148        } else {
149            ApiError::with_message(code, err.message)
150        }
151    }
152}
153
154// ============================================================================
155// Request/Response Types
156// ============================================================================
157
158/// Request to bind a license to hardware.
159#[derive(Debug, Deserialize)]
160#[cfg_attr(feature = "openapi", derive(ToSchema))]
161pub struct BindRequest {
162    /// The human-readable license key (e.g., "LIC-XXXX-XXXX-XXXX")
163    pub license_key: String,
164    /// Hardware fingerprint (SHA-256 hash)
165    pub hardware_id: String,
166    /// Optional device name for display purposes
167    #[serde(default)]
168    pub device_name: Option<String>,
169    /// Optional device info (OS, CPU, etc.)
170    #[serde(default)]
171    pub device_info: Option<String>,
172}
173
174/// Response from a successful bind operation.
175#[derive(Debug, Serialize)]
176#[cfg_attr(feature = "openapi", derive(ToSchema))]
177pub struct BindResponse {
178    pub success: bool,
179    pub license_id: String,
180    pub features: Vec<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub tier: Option<String>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub expires_at: Option<String>,
185}
186
187/// Request to release a license from hardware.
188#[derive(Debug, Deserialize)]
189#[cfg_attr(feature = "openapi", derive(ToSchema))]
190pub struct ReleaseRequest {
191    /// The human-readable license key
192    pub license_key: String,
193    /// Hardware fingerprint to verify ownership
194    pub hardware_id: String,
195}
196
197/// Response from a release operation.
198#[derive(Debug, Serialize)]
199#[cfg_attr(feature = "openapi", derive(ToSchema))]
200pub struct ReleaseResponse {
201    pub success: bool,
202    pub message: String,
203}
204
205/// Request to validate a license.
206#[derive(Debug, Deserialize)]
207#[cfg_attr(feature = "openapi", derive(ToSchema))]
208pub struct ValidateRequest {
209    /// The human-readable license key
210    pub license_key: String,
211    /// Hardware fingerprint to verify binding
212    pub hardware_id: String,
213}
214
215/// Response from validation.
216#[derive(Debug, Serialize)]
217#[cfg_attr(feature = "openapi", derive(ToSchema))]
218pub struct ValidateResponse {
219    pub valid: bool,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub license_id: Option<String>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub features: Option<Vec<String>>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub tier: Option<String>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub expires_at: Option<String>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub grace_period_ends_at: Option<String>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub warning: Option<String>,
232    /// Organization ID (for multi-seat licenses). Falls back to license_id if not set.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub org_id: Option<String>,
235    /// Organization name. Falls back to org_id if not set.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub org_name: Option<String>,
238    /// Bandwidth used this billing period (bytes).
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub bandwidth_used_bytes: Option<i64>,
241    /// Bandwidth limit for this license (bytes). None means unlimited.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub bandwidth_limit_bytes: Option<i64>,
244}
245
246/// Request for validate-or-bind operation.
247#[derive(Debug, Deserialize)]
248#[cfg_attr(feature = "openapi", derive(ToSchema))]
249pub struct ValidateOrBindRequest {
250    /// The human-readable license key
251    pub license_key: String,
252    /// Hardware fingerprint
253    pub hardware_id: String,
254    /// Optional device name (used if binding)
255    #[serde(default)]
256    pub device_name: Option<String>,
257    /// Optional device info (used if binding)
258    #[serde(default)]
259    pub device_info: Option<String>,
260}
261
262/// Request for heartbeat.
263#[derive(Debug, Deserialize)]
264#[cfg_attr(feature = "openapi", derive(ToSchema))]
265pub struct ClientHeartbeatRequest {
266    /// The human-readable license key
267    pub license_key: String,
268    /// Hardware fingerprint to verify binding
269    pub hardware_id: String,
270}
271
272/// Response from heartbeat.
273#[derive(Debug, Serialize)]
274#[cfg_attr(feature = "openapi", derive(ToSchema))]
275pub struct ClientHeartbeatResponse {
276    pub success: bool,
277    pub server_time: String,
278}
279
280/// Request to validate a specific feature.
281#[derive(Debug, Deserialize)]
282#[cfg_attr(feature = "openapi", derive(ToSchema))]
283pub struct ValidateFeatureRequest {
284    /// The human-readable license key
285    pub license_key: String,
286    /// Hardware fingerprint to verify binding
287    pub hardware_id: String,
288    /// The feature to validate
289    pub feature: String,
290}
291
292/// Response from feature validation.
293#[derive(Debug, Serialize)]
294#[cfg_attr(feature = "openapi", derive(ToSchema))]
295pub struct ValidateFeatureResponse {
296    /// Whether the feature is allowed
297    pub allowed: bool,
298    /// Optional message explaining the result
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub message: Option<String>,
301    /// The license tier (if applicable)
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub tier: Option<String>,
304}
305
306// ============================================================================
307// Handlers
308// ============================================================================
309
310/// Bind a license to hardware.
311///
312/// # Behavior
313/// - Checks if license exists and is valid
314/// - If unbound, binds to the provided hardware
315/// - If already bound to same hardware, returns success
316/// - If bound to different hardware, returns ALREADY_BOUND error
317#[cfg_attr(feature = "openapi", utoipa::path(
318    post,
319    path = "/api/v1/client/bind",
320    tag = "client",
321    request_body = BindRequest,
322    responses(
323        (status = 200, description = "License bound successfully", body = BindResponse),
324        (status = 400, description = "Invalid request", body = ClientError),
325        (status = 404, description = "License not found", body = ClientError),
326        (status = 409, description = "License already bound to different device", body = ClientError),
327    )
328))]
329pub async fn bind_handler(
330    State(state): State<AppState>,
331    Json(req): Json<BindRequest>,
332) -> Result<Json<BindResponse>, ClientError> {
333    info!("Bind request for license_key={}", req.license_key);
334
335    // Find the license
336    let license = state
337        .db
338        .get_license_by_key(&req.license_key)
339        .await
340        .map_err(|e| {
341            warn!("Database error: {}", e);
342            ClientError::new(ClientErrorCode::InternalError, "Database error")
343        })?
344        .ok_or_else(|| {
345            warn!("License not found: {}", req.license_key);
346            ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
347        })?;
348
349    // Check license status
350    if license.is_blacklisted == Some(true) {
351        return Err(ClientError::new(
352            ClientErrorCode::LicenseBlacklisted,
353            "License is blacklisted",
354        ));
355    }
356    if license.status == "revoked" {
357        return Err(ClientError::new(
358            ClientErrorCode::LicenseRevoked,
359            "License has been revoked",
360        ));
361    }
362    if license.status == "suspended" && !license.is_in_grace_period() {
363        return Err(ClientError::new(
364            ClientErrorCode::LicenseSuspended,
365            "License is suspended",
366        ));
367    }
368    if license.status != "active" && license.status != "suspended" {
369        return Err(ClientError::new(
370            ClientErrorCode::LicenseInactive,
371            format!("License status is '{}'", license.status),
372        ));
373    }
374    if license.is_expired() {
375        return Err(ClientError::new(
376            ClientErrorCode::LicenseExpired,
377            "License has expired",
378        ));
379    }
380
381    // Check if already bound
382    if license.is_bound() {
383        if license.hardware_id.as_deref() == Some(&req.hardware_id) {
384            // Already bound to this hardware - return success
385            info!("License {} already bound to this hardware", req.license_key);
386            return Ok(Json(BindResponse {
387                success: true,
388                license_id: license.license_id,
389                features: parse_features(&license.features),
390                tier: license.tier,
391                expires_at: license.expires_at.map(|d| d.to_string()),
392            }));
393        } else {
394            // Bound to different hardware
395            return Err(ClientError::new(
396                ClientErrorCode::AlreadyBound,
397                "License is already bound to a different device",
398            )
399            .with_bound_device(license.device_name));
400        }
401    }
402
403    // Bind the license
404    state
405        .db
406        .bind_license(
407            &license.license_id,
408            &req.hardware_id,
409            req.device_name.as_deref(),
410            req.device_info.as_deref(),
411        )
412        .await
413        .map_err(|e| {
414            warn!("Failed to bind license: {}", e);
415            ClientError::new(ClientErrorCode::InternalError, "Failed to bind license")
416        })?;
417
418    // Record binding history
419    let _ = state
420        .db
421        .record_binding_history(
422            &license.license_id,
423            BindingAction::Bind,
424            Some(&req.hardware_id),
425            req.device_name.as_deref(),
426            req.device_info.as_deref(),
427            PerformedBy::Client,
428            None,
429        )
430        .await;
431
432    // Log structured license binding event
433    log_license_binding_event(
434        LicenseEvent::Bound,
435        &req.license_key,
436        &req.hardware_id,
437        req.device_name.as_deref(),
438    );
439
440    Ok(Json(BindResponse {
441        success: true,
442        license_id: license.license_id,
443        features: parse_features(&license.features),
444        tier: license.tier,
445        expires_at: license.expires_at.map(|d| d.to_string()),
446    }))
447}
448
449/// Release a license from hardware.
450///
451/// # Behavior
452/// - Verifies hardware_id matches the bound hardware
453/// - Clears hardware binding fields
454/// - Records release in binding history
455#[cfg_attr(feature = "openapi", utoipa::path(
456    post,
457    path = "/api/v1/client/release",
458    tag = "client",
459    request_body = ReleaseRequest,
460    responses(
461        (status = 200, description = "License released successfully", body = ReleaseResponse),
462        (status = 403, description = "Hardware mismatch", body = ClientError),
463        (status = 404, description = "License not found", body = ClientError),
464        (status = 409, description = "License not bound", body = ClientError),
465    )
466))]
467pub async fn release_handler(
468    State(state): State<AppState>,
469    Json(req): Json<ReleaseRequest>,
470) -> Result<Json<ReleaseResponse>, ClientError> {
471    info!("Release request for license_key={}", req.license_key);
472
473    // Find the license
474    let license = state
475        .db
476        .get_license_by_key(&req.license_key)
477        .await
478        .map_err(|e| {
479            warn!("Database error: {}", e);
480            ClientError::new(ClientErrorCode::InternalError, "Database error")
481        })?
482        .ok_or_else(|| {
483            warn!("License not found: {}", req.license_key);
484            ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
485        })?;
486
487    // Check if bound
488    if !license.is_bound() {
489        return Err(ClientError::new(
490            ClientErrorCode::NotBound,
491            "License is not currently bound",
492        ));
493    }
494
495    // Verify hardware ID matches
496    if license.hardware_id.as_deref() != Some(&req.hardware_id) {
497        return Err(ClientError::new(
498            ClientErrorCode::HardwareMismatch,
499            "Hardware ID does not match the bound device",
500        ));
501    }
502
503    // Release the license
504    state
505        .db
506        .release_license(&license.license_id)
507        .await
508        .map_err(|e| {
509            warn!("Failed to release license: {}", e);
510            ClientError::new(ClientErrorCode::InternalError, "Failed to release license")
511        })?;
512
513    // Record release in history
514    let _ = state
515        .db
516        .record_binding_history(
517            &license.license_id,
518            BindingAction::Release,
519            Some(&req.hardware_id),
520            license.device_name.as_deref(),
521            license.device_info.as_deref(),
522            PerformedBy::Client,
523            None,
524        )
525        .await;
526
527    // Log structured license release event
528    log_license_binding_event(
529        LicenseEvent::Released,
530        &req.license_key,
531        &req.hardware_id,
532        license.device_name.as_deref(),
533    );
534
535    Ok(Json(ReleaseResponse {
536        success: true,
537        message: "License released successfully".to_string(),
538    }))
539}
540
541/// Validate a license.
542///
543/// # Behavior
544/// - Checks license exists
545/// - Checks license is not expired, revoked, suspended, or blacklisted
546/// - Checks license is bound to the provided hardware
547/// - Updates last_seen_at timestamp
548/// - Returns license details including features and tier
549#[cfg_attr(feature = "openapi", utoipa::path(
550    post,
551    path = "/api/v1/client/validate",
552    tag = "client",
553    request_body = ValidateRequest,
554    responses(
555        (status = 200, description = "License validated successfully", body = ValidateResponse),
556        (status = 403, description = "License expired, revoked, or hardware mismatch", body = ClientError),
557        (status = 404, description = "License not found", body = ClientError),
558        (status = 409, description = "License not bound", body = ClientError),
559    )
560))]
561pub async fn validate_handler(
562    State(state): State<AppState>,
563    Json(req): Json<ValidateRequest>,
564) -> Result<Json<ValidateResponse>, ClientError> {
565    info!("Validate request for license_key={}", req.license_key);
566
567    // Find the license
568    let license = state
569        .db
570        .get_license_by_key(&req.license_key)
571        .await
572        .map_err(|e| {
573            warn!("Database error: {}", e);
574            ClientError::new(ClientErrorCode::InternalError, "Database error")
575        })?
576        .ok_or_else(|| {
577            warn!("License not found: {}", req.license_key);
578            ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
579        })?;
580
581    // Check if blacklisted
582    if license.is_blacklisted == Some(true) {
583        return Err(ClientError::new(
584            ClientErrorCode::LicenseBlacklisted,
585            "License is blacklisted",
586        ));
587    }
588
589    // Check if revoked
590    if license.status == "revoked" {
591        return Err(ClientError::new(
592            ClientErrorCode::LicenseRevoked,
593            "License has been revoked",
594        ));
595    }
596
597    // Check if expired
598    if license.is_expired() {
599        return Err(ClientError::new(
600            ClientErrorCode::LicenseExpired,
601            "License has expired",
602        ));
603    }
604
605    // Check if bound
606    if !license.is_bound() {
607        return Err(ClientError::new(
608            ClientErrorCode::NotBound,
609            "License is not bound to any device",
610        ));
611    }
612
613    // Check hardware ID matches
614    if license.hardware_id.as_deref() != Some(&req.hardware_id) {
615        return Err(ClientError::new(
616            ClientErrorCode::HardwareMismatch,
617            "Hardware ID does not match the bound device",
618        ));
619    }
620
621    // Update last_seen_at
622    let _ = state.db.update_last_seen(&license.license_id).await;
623
624    // Handle suspended status (grace period) - check before building response
625    let (grace_period_ends, warning_msg) = if license.status == "suspended" {
626        if license.is_in_grace_period() {
627            (
628                license
629                    .grace_period_ends_at
630                    .map(|d| d.and_utc().to_rfc3339()),
631                Some(
632                    license
633                        .suspension_message
634                        .clone()
635                        .unwrap_or_else(|| "License is in grace period".to_string()),
636                ),
637            )
638        } else {
639            return Err(ClientError::new(
640                ClientErrorCode::LicenseSuspended,
641                "License is suspended and grace period has ended",
642            ));
643        }
644    } else {
645        (None, None)
646    };
647
648    // Check for non-active status
649    if license.status != "active" && license.status != "suspended" {
650        return Err(ClientError::new(
651            ClientErrorCode::LicenseInactive,
652            format!("License status is '{}'", license.status),
653        ));
654    }
655
656    // Fallback: if no org, treat license as its own org
657    let effective_org_id = license
658        .org_id
659        .clone()
660        .unwrap_or_else(|| license.license_id.clone());
661    let effective_org_name = license
662        .org_name
663        .clone()
664        .unwrap_or_else(|| effective_org_id.clone());
665
666    // Build response
667    let response = ValidateResponse {
668        valid: true,
669        license_id: Some(license.license_id),
670        features: Some(parse_features(&license.features)),
671        tier: license.tier,
672        expires_at: license.expires_at.map(|d| d.and_utc().to_rfc3339()),
673        grace_period_ends_at: grace_period_ends,
674        warning: warning_msg,
675        org_id: Some(effective_org_id),
676        org_name: Some(effective_org_name),
677        bandwidth_used_bytes: license.bandwidth_used_bytes,
678        bandwidth_limit_bytes: license.bandwidth_limit_bytes,
679    };
680
681    // Log structured license validation event
682    log_license_event(LicenseEvent::Validated, &req.license_key, None);
683
684    Ok(Json(response))
685}
686
687/// Validate or bind a license.
688///
689/// # Behavior
690/// - If bound to this hardware: validate and return
691/// - If unbound: bind first, then validate
692/// - If bound to other hardware: return ALREADY_BOUND error
693#[cfg_attr(feature = "openapi", utoipa::path(
694    post,
695    path = "/api/v1/client/validate-or-bind",
696    tag = "client",
697    request_body = ValidateOrBindRequest,
698    responses(
699        (status = 200, description = "License validated (and bound if needed)", body = ValidateResponse),
700        (status = 403, description = "License expired, revoked, or invalid", body = ClientError),
701        (status = 404, description = "License not found", body = ClientError),
702        (status = 409, description = "License already bound to different device", body = ClientError),
703    )
704))]
705pub async fn validate_or_bind_handler(
706    State(state): State<AppState>,
707    Json(req): Json<ValidateOrBindRequest>,
708) -> Result<Json<ValidateResponse>, ClientError> {
709    info!(
710        "Validate-or-bind request for license_key={}",
711        req.license_key
712    );
713
714    // Find the license
715    let license = state
716        .db
717        .get_license_by_key(&req.license_key)
718        .await
719        .map_err(|e| {
720            warn!("Database error: {}", e);
721            ClientError::new(ClientErrorCode::InternalError, "Database error")
722        })?
723        .ok_or_else(|| {
724            warn!("License not found: {}", req.license_key);
725            ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
726        })?;
727
728    // Check license validity first
729    if license.is_blacklisted == Some(true) {
730        return Err(ClientError::new(
731            ClientErrorCode::LicenseBlacklisted,
732            "License is blacklisted",
733        ));
734    }
735    if license.status == "revoked" {
736        return Err(ClientError::new(
737            ClientErrorCode::LicenseRevoked,
738            "License has been revoked",
739        ));
740    }
741    if license.is_expired() {
742        return Err(ClientError::new(
743            ClientErrorCode::LicenseExpired,
744            "License has expired",
745        ));
746    }
747    if license.status == "suspended" && !license.is_in_grace_period() {
748        return Err(ClientError::new(
749            ClientErrorCode::LicenseSuspended,
750            "License is suspended",
751        ));
752    }
753    if license.status != "active" && license.status != "suspended" {
754        return Err(ClientError::new(
755            ClientErrorCode::LicenseInactive,
756            format!("License status is '{}'", license.status),
757        ));
758    }
759
760    // Check binding status
761    if license.is_bound() {
762        if license.hardware_id.as_deref() != Some(&req.hardware_id) {
763            // Bound to different hardware
764            return Err(ClientError::new(
765                ClientErrorCode::AlreadyBound,
766                "License is already bound to a different device",
767            )
768            .with_bound_device(license.device_name));
769        }
770        // Already bound to this hardware - just validate
771    } else {
772        // Not bound - bind it first
773        state
774            .db
775            .bind_license(
776                &license.license_id,
777                &req.hardware_id,
778                req.device_name.as_deref(),
779                req.device_info.as_deref(),
780            )
781            .await
782            .map_err(|e| {
783                warn!("Failed to bind license: {}", e);
784                ClientError::new(ClientErrorCode::InternalError, "Failed to bind license")
785            })?;
786
787        // Record binding history
788        let _ = state
789            .db
790            .record_binding_history(
791                &license.license_id,
792                BindingAction::Bind,
793                Some(&req.hardware_id),
794                req.device_name.as_deref(),
795                req.device_info.as_deref(),
796                PerformedBy::Client,
797                None,
798            )
799            .await;
800
801        // Log structured license binding event
802        log_license_binding_event(
803            LicenseEvent::Bound,
804            &req.license_key,
805            &req.hardware_id,
806            req.device_name.as_deref(),
807        );
808    }
809
810    // Update last_seen_at
811    let _ = state.db.update_last_seen(&license.license_id).await;
812
813    // Handle grace period warning - check before building response
814    let (grace_period_ends, warning_msg) =
815        if license.status == "suspended" && license.is_in_grace_period() {
816            (
817                license
818                    .grace_period_ends_at
819                    .map(|d| d.and_utc().to_rfc3339()),
820                Some(
821                    license
822                        .suspension_message
823                        .clone()
824                        .unwrap_or_else(|| "License is in grace period".to_string()),
825                ),
826            )
827        } else {
828            (None, None)
829        };
830
831    // Fallback: if no org, treat license as its own org
832    let effective_org_id = license
833        .org_id
834        .clone()
835        .unwrap_or_else(|| license.license_id.clone());
836    let effective_org_name = license
837        .org_name
838        .clone()
839        .unwrap_or_else(|| effective_org_id.clone());
840
841    // Build response
842    let response = ValidateResponse {
843        valid: true,
844        license_id: Some(license.license_id),
845        features: Some(parse_features(&license.features)),
846        tier: license.tier,
847        expires_at: license.expires_at.map(|d| d.and_utc().to_rfc3339()),
848        grace_period_ends_at: grace_period_ends,
849        warning: warning_msg,
850        org_id: Some(effective_org_id),
851        org_name: Some(effective_org_name),
852        bandwidth_used_bytes: license.bandwidth_used_bytes,
853        bandwidth_limit_bytes: license.bandwidth_limit_bytes,
854    };
855
856    // Log structured validation event
857    log_license_event(LicenseEvent::Validated, &req.license_key, None);
858
859    Ok(Json(response))
860}
861
862/// Client heartbeat endpoint using license key.
863///
864/// # Behavior
865/// - Verifies license exists and is bound to the provided hardware
866/// - Updates last_seen_at timestamp
867/// - Returns server timestamp
868#[cfg_attr(feature = "openapi", utoipa::path(
869    post,
870    path = "/api/v1/client/heartbeat",
871    tag = "client",
872    request_body = ClientHeartbeatRequest,
873    responses(
874        (status = 200, description = "Heartbeat recorded", body = ClientHeartbeatResponse),
875        (status = 403, description = "Hardware mismatch", body = ClientError),
876        (status = 404, description = "License not found", body = ClientError),
877        (status = 409, description = "License not bound", body = ClientError),
878    )
879))]
880pub async fn client_heartbeat_handler(
881    State(state): State<AppState>,
882    Json(req): Json<ClientHeartbeatRequest>,
883) -> Result<Json<ClientHeartbeatResponse>, ClientError> {
884    info!("Heartbeat for license_key={}", req.license_key);
885
886    // Find the license
887    let license = state
888        .db
889        .get_license_by_key(&req.license_key)
890        .await
891        .map_err(|e| {
892            warn!("Database error: {}", e);
893            ClientError::new(ClientErrorCode::InternalError, "Database error")
894        })?
895        .ok_or_else(|| {
896            warn!("License not found: {}", req.license_key);
897            ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
898        })?;
899
900    // Check if bound
901    if !license.is_bound() {
902        return Err(ClientError::new(
903            ClientErrorCode::NotBound,
904            "License is not bound to any device",
905        ));
906    }
907
908    // Verify hardware ID matches
909    if license.hardware_id.as_deref() != Some(&req.hardware_id) {
910        return Err(ClientError::new(
911            ClientErrorCode::HardwareMismatch,
912            "Hardware ID does not match the bound device",
913        ));
914    }
915
916    // Update last_seen_at
917    state
918        .db
919        .update_last_seen(&license.license_id)
920        .await
921        .map_err(|e| {
922            warn!("Failed to update last_seen: {}", e);
923            ClientError::new(ClientErrorCode::InternalError, "Failed to update heartbeat")
924        })?;
925
926    // Log structured heartbeat event
927    log_license_event(LicenseEvent::Heartbeat, &req.license_key, None);
928
929    Ok(Json(ClientHeartbeatResponse {
930        success: true,
931        server_time: Utc::now().to_rfc3339(),
932    }))
933}
934
935/// Validate a specific feature for a license.
936///
937/// # Behavior
938/// - Performs full license validation first (same checks as validate)
939/// - Checks if the feature is in the license's features list
940/// - Checks if the feature is in the tier's features (if tier is set)
941/// - Checks if the feature is restricted due to quota exceeded
942/// - Returns allowed: true/false with appropriate message
943#[cfg_attr(feature = "openapi", utoipa::path(
944    post,
945    path = "/api/v1/client/validate-feature",
946    tag = "client",
947    request_body = ValidateFeatureRequest,
948    responses(
949        (status = 200, description = "Feature validation result", body = ValidateFeatureResponse),
950        (status = 403, description = "Feature not included or quota exceeded", body = ClientError),
951        (status = 404, description = "License not found", body = ClientError),
952    )
953))]
954pub async fn validate_feature_handler(
955    State(state): State<AppState>,
956    Json(req): Json<ValidateFeatureRequest>,
957) -> Result<Json<ValidateFeatureResponse>, ClientError> {
958    info!(
959        "Validate feature '{}' for license_key={}",
960        req.feature, req.license_key
961    );
962
963    // Find the license
964    let license = state
965        .db
966        .get_license_by_key(&req.license_key)
967        .await
968        .map_err(|e| {
969            warn!("Database error: {}", e);
970            ClientError::new(ClientErrorCode::InternalError, "Database error")
971        })?
972        .ok_or_else(|| {
973            warn!("License not found: {}", req.license_key);
974            ClientError::new(ClientErrorCode::LicenseNotFound, "License key not found")
975        })?;
976
977    // Check if blacklisted
978    if license.is_blacklisted == Some(true) {
979        return Err(ClientError::new(
980            ClientErrorCode::LicenseBlacklisted,
981            "License is blacklisted",
982        ));
983    }
984
985    // Check if revoked
986    if license.status == "revoked" {
987        return Err(ClientError::new(
988            ClientErrorCode::LicenseRevoked,
989            "License has been revoked",
990        ));
991    }
992
993    // Check if expired
994    if license.is_expired() {
995        return Err(ClientError::new(
996            ClientErrorCode::LicenseExpired,
997            "License has expired",
998        ));
999    }
1000
1001    // Check if suspended (but allow if in grace period)
1002    if license.status == "suspended" && !license.is_in_grace_period() {
1003        return Err(ClientError::new(
1004            ClientErrorCode::LicenseSuspended,
1005            "License is suspended and grace period has ended",
1006        ));
1007    }
1008
1009    // Check if bound
1010    if !license.is_bound() {
1011        return Err(ClientError::new(
1012            ClientErrorCode::NotBound,
1013            "License is not bound to any device",
1014        ));
1015    }
1016
1017    // Check hardware ID matches
1018    if license.hardware_id.as_deref() != Some(&req.hardware_id) {
1019        return Err(ClientError::new(
1020            ClientErrorCode::HardwareMismatch,
1021            "Hardware ID does not match the bound device",
1022        ));
1023    }
1024
1025    // Check for non-active status
1026    if license.status != "active" && license.status != "suspended" {
1027        return Err(ClientError::new(
1028            ClientErrorCode::LicenseInactive,
1029            format!("License status is '{}'", license.status),
1030        ));
1031    }
1032
1033    // Update last_seen_at
1034    let _ = state.db.update_last_seen(&license.license_id).await;
1035
1036    // Get license features (from JSON string)
1037    let license_features = parse_features(&license.features);
1038
1039    // Get tier features (if tier is set)
1040    let tier_features: Vec<String> = license
1041        .tier
1042        .as_ref()
1043        .and_then(|t| get_tier_config(t))
1044        .map(|tier| tier.config.features)
1045        .unwrap_or_default();
1046
1047    // Check if feature is in license features or tier features
1048    let feature_in_license = license_features.iter().any(|f| f == &req.feature);
1049    let feature_in_tier = tier_features.iter().any(|f| f == &req.feature);
1050
1051    if !feature_in_license && !feature_in_tier {
1052        info!(
1053            "Feature '{}' not included for license {}",
1054            req.feature, req.license_key
1055        );
1056        return Err(ClientError::new(
1057            ClientErrorCode::FeatureNotIncluded,
1058            format!(
1059                "Feature '{}' is not included in your license or tier",
1060                req.feature
1061            ),
1062        ));
1063    }
1064
1065    // TODO: Check quota_restricted_features when quota fields are added to database
1066    // For now, quota checking is a stub that always passes
1067    // When quota fields are added, check if quota_exceeded is true and
1068    // if the feature is in quota_restricted_features
1069
1070    info!(
1071        "Feature '{}' allowed for license {}",
1072        req.feature, req.license_key
1073    );
1074
1075    Ok(Json(ValidateFeatureResponse {
1076        allowed: true,
1077        message: Some(format!("Feature '{}' is enabled", req.feature)),
1078        tier: license.tier,
1079    }))
1080}
1081
1082// ============================================================================
1083// Helpers
1084// ============================================================================
1085
1086/// Parse features from JSON string to Vec<String>.
1087fn parse_features(features: &Option<String>) -> Vec<String> {
1088    features
1089        .as_ref()
1090        .and_then(|f| serde_json::from_str::<Vec<String>>(f).ok())
1091        .unwrap_or_default()
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097
1098    #[test]
1099    fn error_code_serialization() {
1100        let err = ClientError::new(ClientErrorCode::LicenseNotFound, "Not found");
1101        let json = serde_json::to_string(&err).unwrap();
1102        assert!(json.contains("LICENSE_NOT_FOUND"));
1103    }
1104
1105    #[test]
1106    fn error_status_codes() {
1107        assert_eq!(
1108            ClientError::new(ClientErrorCode::LicenseNotFound, "").status_code(),
1109            StatusCode::NOT_FOUND
1110        );
1111        assert_eq!(
1112            ClientError::new(ClientErrorCode::AlreadyBound, "").status_code(),
1113            StatusCode::CONFLICT
1114        );
1115        assert_eq!(
1116            ClientError::new(ClientErrorCode::HardwareMismatch, "").status_code(),
1117            StatusCode::FORBIDDEN
1118        );
1119        assert_eq!(
1120            ClientError::new(ClientErrorCode::InvalidRequest, "").status_code(),
1121            StatusCode::BAD_REQUEST
1122        );
1123    }
1124
1125    #[test]
1126    fn parse_features_empty() {
1127        assert_eq!(parse_features(&None), Vec::<String>::new());
1128        assert_eq!(parse_features(&Some("".to_string())), Vec::<String>::new());
1129    }
1130
1131    #[test]
1132    fn parse_features_valid() {
1133        let features = Some(r#"["feature_a", "feature_b"]"#.to_string());
1134        assert_eq!(
1135            parse_features(&features),
1136            vec!["feature_a".to_string(), "feature_b".to_string()]
1137        );
1138    }
1139
1140    #[test]
1141    fn feature_error_codes_serialization() {
1142        let err = ClientError::new(ClientErrorCode::FeatureNotIncluded, "Feature not included");
1143        let json = serde_json::to_string(&err).unwrap();
1144        assert!(json.contains("FEATURE_NOT_INCLUDED"));
1145
1146        let err = ClientError::new(ClientErrorCode::QuotaExceeded, "Quota exceeded");
1147        let json = serde_json::to_string(&err).unwrap();
1148        assert!(json.contains("QUOTA_EXCEEDED"));
1149    }
1150
1151    #[test]
1152    fn feature_error_status_codes() {
1153        assert_eq!(
1154            ClientError::new(ClientErrorCode::FeatureNotIncluded, "").status_code(),
1155            StatusCode::FORBIDDEN
1156        );
1157        assert_eq!(
1158            ClientError::new(ClientErrorCode::QuotaExceeded, "").status_code(),
1159            StatusCode::FORBIDDEN
1160        );
1161    }
1162}