1use 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#[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 LicenseNotFound,
41 AlreadyBound,
43 NotBound,
45 HardwareMismatch,
47 LicenseExpired,
49 LicenseRevoked,
51 LicenseSuspended,
53 LicenseBlacklisted,
55 LicenseInactive,
57 FeatureNotIncluded,
59 QuotaExceeded,
61 InvalidRequest,
63 InternalError,
65}
66
67#[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#[derive(Debug, Deserialize)]
160#[cfg_attr(feature = "openapi", derive(ToSchema))]
161pub struct BindRequest {
162 pub license_key: String,
164 pub hardware_id: String,
166 #[serde(default)]
168 pub device_name: Option<String>,
169 #[serde(default)]
171 pub device_info: Option<String>,
172}
173
174#[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#[derive(Debug, Deserialize)]
189#[cfg_attr(feature = "openapi", derive(ToSchema))]
190pub struct ReleaseRequest {
191 pub license_key: String,
193 pub hardware_id: String,
195}
196
197#[derive(Debug, Serialize)]
199#[cfg_attr(feature = "openapi", derive(ToSchema))]
200pub struct ReleaseResponse {
201 pub success: bool,
202 pub message: String,
203}
204
205#[derive(Debug, Deserialize)]
207#[cfg_attr(feature = "openapi", derive(ToSchema))]
208pub struct ValidateRequest {
209 pub license_key: String,
211 pub hardware_id: String,
213}
214
215#[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 #[serde(skip_serializing_if = "Option::is_none")]
234 pub org_id: Option<String>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub org_name: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub bandwidth_used_bytes: Option<i64>,
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub bandwidth_limit_bytes: Option<i64>,
244}
245
246#[derive(Debug, Deserialize)]
248#[cfg_attr(feature = "openapi", derive(ToSchema))]
249pub struct ValidateOrBindRequest {
250 pub license_key: String,
252 pub hardware_id: String,
254 #[serde(default)]
256 pub device_name: Option<String>,
257 #[serde(default)]
259 pub device_info: Option<String>,
260}
261
262#[derive(Debug, Deserialize)]
264#[cfg_attr(feature = "openapi", derive(ToSchema))]
265pub struct ClientHeartbeatRequest {
266 pub license_key: String,
268 pub hardware_id: String,
270}
271
272#[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#[derive(Debug, Deserialize)]
282#[cfg_attr(feature = "openapi", derive(ToSchema))]
283pub struct ValidateFeatureRequest {
284 pub license_key: String,
286 pub hardware_id: String,
288 pub feature: String,
290}
291
292#[derive(Debug, Serialize)]
294#[cfg_attr(feature = "openapi", derive(ToSchema))]
295pub struct ValidateFeatureResponse {
296 pub allowed: bool,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub message: Option<String>,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub tier: Option<String>,
304}
305
306#[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 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 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 if license.is_bound() {
383 if license.hardware_id.as_deref() == Some(&req.hardware_id) {
384 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 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 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 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_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#[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 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 if !license.is_bound() {
489 return Err(ClientError::new(
490 ClientErrorCode::NotBound,
491 "License is not currently bound",
492 ));
493 }
494
495 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 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 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_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#[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 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 if license.is_blacklisted == Some(true) {
583 return Err(ClientError::new(
584 ClientErrorCode::LicenseBlacklisted,
585 "License is blacklisted",
586 ));
587 }
588
589 if license.status == "revoked" {
591 return Err(ClientError::new(
592 ClientErrorCode::LicenseRevoked,
593 "License has been revoked",
594 ));
595 }
596
597 if license.is_expired() {
599 return Err(ClientError::new(
600 ClientErrorCode::LicenseExpired,
601 "License has expired",
602 ));
603 }
604
605 if !license.is_bound() {
607 return Err(ClientError::new(
608 ClientErrorCode::NotBound,
609 "License is not bound to any device",
610 ));
611 }
612
613 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 let _ = state.db.update_last_seen(&license.license_id).await;
623
624 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 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 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 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_license_event(LicenseEvent::Validated, &req.license_key, None);
683
684 Ok(Json(response))
685}
686
687#[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 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 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 if license.is_bound() {
762 if license.hardware_id.as_deref() != Some(&req.hardware_id) {
763 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 } else {
772 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 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_license_binding_event(
803 LicenseEvent::Bound,
804 &req.license_key,
805 &req.hardware_id,
806 req.device_name.as_deref(),
807 );
808 }
809
810 let _ = state.db.update_last_seen(&license.license_id).await;
812
813 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 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 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_license_event(LicenseEvent::Validated, &req.license_key, None);
858
859 Ok(Json(response))
860}
861
862#[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 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 if !license.is_bound() {
902 return Err(ClientError::new(
903 ClientErrorCode::NotBound,
904 "License is not bound to any device",
905 ));
906 }
907
908 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 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_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#[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 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 if license.is_blacklisted == Some(true) {
979 return Err(ClientError::new(
980 ClientErrorCode::LicenseBlacklisted,
981 "License is blacklisted",
982 ));
983 }
984
985 if license.status == "revoked" {
987 return Err(ClientError::new(
988 ClientErrorCode::LicenseRevoked,
989 "License has been revoked",
990 ));
991 }
992
993 if license.is_expired() {
995 return Err(ClientError::new(
996 ClientErrorCode::LicenseExpired,
997 "License has expired",
998 ));
999 }
1000
1001 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 if !license.is_bound() {
1011 return Err(ClientError::new(
1012 ClientErrorCode::NotBound,
1013 "License is not bound to any device",
1014 ));
1015 }
1016
1017 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 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 let _ = state.db.update_last_seen(&license.license_id).await;
1035
1036 let license_features = parse_features(&license.features);
1038
1039 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 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 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
1082fn 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}