Skip to main content

max / makenotwork

24.9 KB · 789 lines History Blame Raw
1 //! License key management and public activation/validation API.
2 //!
3 //! See also: `/docs/developer/license-keys`
4
5 use axum::{
6 Form, Json,
7 extract::{Path, State},
8 http::{StatusCode, header::HeaderMap},
9 response::{Html, IntoResponse, Response},
10 };
11 use chrono::{DateTime, Datelike, Utc};
12 use serde::{Deserialize, Serialize};
13
14 use crate::config::Config;
15 use sqlx::PgPool;
16
17 use crate::{
18 auth::AuthUser,
19 db::{self, ItemId, KeyCode, LicenseKeyId},
20 error::{AppError, Result, ResultExt},
21 helpers::{self, hx_toast, is_htmx_request},
22 templates::{ItemLicenseKeysTemplate, SaveStatusTemplate},
23 types::LicenseKeyRow,
24 types::ListResponse,
25 validation,
26 };
27 use jsonwebtoken::{EncodingKey, Header, encode};
28
29 use super::verify_item_ownership;
30
31 /// JSON response for key validation/activation.
32 #[derive(Debug, Serialize, utoipa::ToSchema)]
33 pub(crate) struct ValidateKeyResponse {
34 valid: bool,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 activated: Option<bool>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 error: Option<&'static str>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 license: Option<ValidateKeyLicense>,
41 }
42
43 /// License details within a validation response.
44 #[derive(Debug, Serialize, utoipa::ToSchema)]
45 pub(crate) struct ValidateKeyLicense {
46 #[schema(value_type = String)]
47 item_id: ItemId,
48 max_activations: Option<i32>,
49 activation_count: i32,
50 #[schema(value_type = String)]
51 created_at: DateTime<Utc>,
52 }
53
54 /// JSON response for key deactivation.
55 #[derive(Debug, Serialize, utoipa::ToSchema)]
56 pub(crate) struct DeactivateKeyResponse {
57 success: bool,
58 message: &'static str,
59 }
60
61 /// JSON response for key status check.
62 #[derive(Debug, Serialize, utoipa::ToSchema)]
63 pub(crate) struct KeyStatusResponse {
64 valid: bool,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 error: Option<&'static str>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 license: Option<KeyStatusLicense>,
69 }
70
71 /// License details within a status response.
72 #[derive(Debug, Serialize, utoipa::ToSchema)]
73 pub(crate) struct KeyStatusLicense {
74 #[schema(value_type = String)]
75 item_id: ItemId,
76 max_activations: Option<i32>,
77 activation_count: i32,
78 remaining_activations: Option<i32>,
79 #[schema(value_type = String)]
80 created_at: DateTime<Utc>,
81 }
82
83 /// JSON response for key generation and listing.
84 #[derive(Debug, Serialize)]
85 struct GenerateKeyResponse {
86 id: LicenseKeyId,
87 key_code: KeyCode,
88 max_activations: Option<i32>,
89 created_at: DateTime<Utc>,
90 }
91
92 // License Key API, Public (no auth, rate-limited)
93
94 /// JSON input for validating/activating a license key.
95 #[derive(Debug, Deserialize, utoipa::ToSchema)]
96 pub(crate) struct ValidateKeyRequest {
97 #[schema(value_type = String)]
98 pub key: KeyCode,
99 pub machine_id: String,
100 pub label: Option<String>,
101 }
102
103 /// Validate a license key and optionally activate it on a machine.
104 #[utoipa::path(
105 post,
106 path = "/api/v1/keys/validate",
107 tag = "License Keys",
108 request_body = ValidateKeyRequest,
109 responses(
110 (status = 200, description = "Validation result", body = ValidateKeyResponse),
111 ),
112 )]
113 #[tracing::instrument(skip_all, name = "license_keys::validate_key")]
114 pub(super) async fn validate_key(
115 State(db): State<PgPool>,
116 Json(req): Json<ValidateKeyRequest>,
117 ) -> Result<impl IntoResponse> {
118 // key is auto-validated by KeyCode deserialization
119 validation::validate_machine_id(&req.machine_id)?;
120 if let Some(ref label) = req.label {
121 validation::validate_activation_label(label)?;
122 }
123
124 // Look up key
125 let Some(key) = db::license_keys::get_license_key_by_code(&db, &req.key).await? else {
126 return Ok(Json(ValidateKeyResponse {
127 valid: false,
128 activated: None,
129 error: Some("invalid_key"),
130 license: None,
131 }));
132 };
133
134 // Check revoked
135 if key.revoked_at.is_some() {
136 return Ok(Json(ValidateKeyResponse {
137 valid: false,
138 activated: None,
139 error: Some("key_revoked"),
140 license: None,
141 }));
142 }
143
144 // Check if already activated on this machine (fast path, no lock needed)
145 if let Some(activation) = db::license_keys::get_activation(&db, key.id, &req.machine_id).await?
146 && activation.is_active
147 {
148 db::license_keys::touch_activation(&db, activation.id).await?;
149 // Report the authoritative denormalized count, not the pre-lookup read,
150 // so a concurrent activation on another machine isn't reflected stale.
151 let activation_count = db::license_keys::get_activation_count(&db, key.id).await?;
152 return Ok(Json(ValidateKeyResponse {
153 valid: true,
154 activated: None,
155 error: None,
156 license: Some(ValidateKeyLicense {
157 item_id: key.item_id,
158 max_activations: key.max_activations,
159 activation_count,
160 created_at: key.created_at,
161 }),
162 }));
163 }
164
165 // Atomically check limit and create activation (serialized via FOR UPDATE)
166 let activation =
167 db::license_keys::try_create_activation(&db, key.id, &req.machine_id, req.label.as_deref())
168 .await?;
169
170 if activation.is_none() {
171 return Ok(Json(ValidateKeyResponse {
172 valid: false,
173 activated: None,
174 error: Some("activation_limit_reached"),
175 license: None,
176 }));
177 }
178
179 // try_create_activation recomputed the denormalized count under the row
180 // lock; read it back rather than guessing `+ 1` (which is wrong if another
181 // machine activated concurrently).
182 let activation_count = db::license_keys::get_activation_count(&db, key.id).await?;
183 Ok(Json(ValidateKeyResponse {
184 valid: true,
185 activated: Some(true),
186 error: None,
187 license: Some(ValidateKeyLicense {
188 item_id: key.item_id,
189 max_activations: key.max_activations,
190 activation_count,
191 created_at: key.created_at,
192 }),
193 }))
194 }
195
196 /// JSON input for deactivating a machine.
197 #[derive(Debug, Deserialize, utoipa::ToSchema)]
198 pub(crate) struct DeactivateKeyRequest {
199 #[schema(value_type = String)]
200 pub key: KeyCode,
201 pub machine_id: String,
202 }
203
204 /// Release an activation slot (user uninstalls).
205 #[utoipa::path(
206 post,
207 path = "/api/v1/keys/deactivate",
208 tag = "License Keys",
209 request_body = DeactivateKeyRequest,
210 responses(
211 (status = 200, description = "Deactivation result", body = DeactivateKeyResponse),
212 ),
213 )]
214 #[tracing::instrument(skip_all, name = "license_keys::deactivate_key")]
215 pub(super) async fn deactivate_key(
216 State(db): State<PgPool>,
217 Json(req): Json<DeactivateKeyRequest>,
218 ) -> Result<impl IntoResponse> {
219 validation::validate_machine_id(&req.machine_id)?;
220
221 let Some(key) = db::license_keys::get_license_key_by_code(&db, &req.key).await? else {
222 return Ok(Json(DeactivateKeyResponse {
223 success: false,
224 message: "Invalid key",
225 }));
226 };
227
228 let deactivated = db::license_keys::deactivate_machine(&db, key.id, &req.machine_id).await?;
229
230 Ok(Json(DeactivateKeyResponse {
231 success: deactivated,
232 message: if deactivated {
233 "Machine deactivated"
234 } else {
235 "No active activation found"
236 },
237 }))
238 }
239
240 /// Compute the status response for a key code. Shared by the GET-path form and
241 /// the POST-body form so they can't drift.
242 async fn resolve_key_status(db: &PgPool, key_code: &KeyCode) -> Result<KeyStatusResponse> {
243 let Some(key) = db::license_keys::get_license_key_by_code(db, key_code).await? else {
244 return Ok(KeyStatusResponse {
245 valid: false,
246 error: Some("invalid_key"),
247 license: None,
248 });
249 };
250
251 if key.revoked_at.is_some() {
252 return Ok(KeyStatusResponse {
253 valid: false,
254 error: Some("key_revoked"),
255 license: None,
256 });
257 }
258
259 // Clamp at 0: an admin can lower max_activations below the current activation
260 // count, which would otherwise surface a negative remaining count publicly.
261 let remaining = key
262 .max_activations
263 .map(|max| (max - key.activation_count).max(0));
264
265 Ok(KeyStatusResponse {
266 valid: true,
267 error: None,
268 license: Some(KeyStatusLicense {
269 item_id: key.item_id,
270 max_activations: key.max_activations,
271 activation_count: key.activation_count,
272 remaining_activations: remaining,
273 created_at: key.created_at,
274 }),
275 })
276 }
277
278 /// Request body for the POST status check.
279 #[derive(Debug, Deserialize, utoipa::ToSchema)]
280 pub(crate) struct KeyStatusRequest {
281 #[schema(value_type = String)]
282 pub key: KeyCode,
283 }
284
285 /// Quick validity check without activating, key in the POST body.
286 ///
287 /// Prefer this over the GET `/keys/{key_code}/status` form: the key code is a
288 /// purchase-proof secret, and putting it in the URL path leaks it into access
289 /// and proxy logs. This matches the other license endpoints (validate,
290 /// deactivate, verify), which all carry the key in the body.
291 #[utoipa::path(
292 post,
293 path = "/api/v1/keys/status",
294 tag = "License Keys",
295 request_body = KeyStatusRequest,
296 responses(
297 (status = 200, description = "Key status", body = KeyStatusResponse),
298 ),
299 )]
300 #[tracing::instrument(skip_all, name = "license_keys::key_status_post")]
301 pub(super) async fn key_status_post(
302 State(db): State<PgPool>,
303 Json(req): Json<KeyStatusRequest>,
304 ) -> Result<impl IntoResponse> {
305 Ok(Json(resolve_key_status(&db, &req.key).await?))
306 }
307
308 /// Quick validity check without activating.
309 ///
310 /// DEPRECATED: the key code rides in the URL path, which leaks this
311 /// purchase-proof secret into access/proxy logs. Use `POST /api/v1/keys/status`
312 /// (key in the body) instead. Kept for backward compatibility with SDK
313 /// consumers that predate the POST form.
314 #[utoipa::path(
315 get,
316 path = "/api/v1/keys/{key_code}/status",
317 tag = "License Keys",
318 params(("key_code" = String, Path, description = "The license key code")),
319 responses(
320 (status = 200, description = "Key status (DEPRECATED. Prefer POST /api/v1/keys/status)", body = KeyStatusResponse),
321 ),
322 )]
323 #[tracing::instrument(skip_all, name = "license_keys::key_status")]
324 pub(super) async fn key_status(
325 State(db): State<PgPool>,
326 Path(key_code): Path<String>,
327 ) -> Result<impl IntoResponse> {
328 let key_code = KeyCode::new(&key_code)?;
329 Ok(Json(resolve_key_status(&db, &key_code).await?))
330 }
331
332 // License Key API, Creator management (auth required)
333
334 /// Form input for updating license key settings on an item.
335 #[derive(Debug, Deserialize)]
336 pub(crate) struct UpdateLicenseSettingsForm {
337 pub enable_license_keys: Option<String>,
338 pub default_max_activations: Option<i32>,
339 pub license_preset: Option<String>,
340 pub custom_license_text: Option<String>,
341 }
342
343 /// Toggle license keys and set default activation limit for an item.
344 #[tracing::instrument(skip_all, name = "license_keys::update_license_settings")]
345 pub(super) async fn update_license_settings(
346 State(db): State<PgPool>,
347 headers: HeaderMap,
348 AuthUser(user): AuthUser,
349 Path(id): Path<ItemId>,
350 Form(req): Form<UpdateLicenseSettingsForm>,
351 ) -> Result<Response> {
352 user.check_not_suspended()?;
353
354 verify_item_ownership(&db, id, user.id).await?;
355
356 let enable = req.enable_license_keys.is_some();
357 db::items::update_item_license_settings(&db, id, user.id, enable, req.default_max_activations)
358 .await?;
359
360 // License text preset
361 let preset_str = req.license_preset.as_deref().filter(|s| !s.is_empty());
362 if let Some(preset_key) = preset_str {
363 // Validate preset key
364 use crate::license_templates::LicensePreset;
365 let preset: LicensePreset = preset_key
366 .parse()
367 .map_err(|_| crate::error::AppError::validation("Invalid license preset"))?;
368 let custom_text = if preset == LicensePreset::Custom {
369 let text = req.custom_license_text.as_deref().unwrap_or("").trim();
370 if text.is_empty() {
371 return Err(crate::error::AppError::validation(
372 "Custom license text is required when using Custom preset",
373 ));
374 }
375 Some(text)
376 } else {
377 None
378 };
379 db::items::update_item_license_text(&db, id, user.id, Some(preset_key), custom_text)
380 .await?;
381 } else {
382 // Clear license
383 db::items::update_item_license_text(&db, id, user.id, None, None).await?;
384 }
385
386 if is_htmx_request(&headers) {
387 return Ok(Html(
388 SaveStatusTemplate {
389 success: true,
390 message: "License settings saved".to_string(),
391 }
392 .render_string()?,
393 )
394 .into_response());
395 }
396
397 Ok(StatusCode::NO_CONTENT.into_response())
398 }
399
400 /// Form input for generating a license key.
401 #[derive(Debug, Deserialize)]
402 pub(crate) struct GenerateKeyForm {
403 pub max_activations: Option<i32>,
404 }
405
406 /// Generate a new license key for an item (creator dashboard).
407 #[tracing::instrument(skip_all, name = "license_keys::generate_key")]
408 pub(super) async fn generate_key(
409 State(db): State<PgPool>,
410 headers: HeaderMap,
411 AuthUser(user): AuthUser,
412 Path(item_id): Path<ItemId>,
413 Form(req): Form<GenerateKeyForm>,
414 ) -> Result<Response> {
415 user.check_not_suspended()?;
416
417 let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?;
418
419 if !item.enable_license_keys {
420 return Err(AppError::BadRequest(
421 "License keys are not enabled for this item".to_string(),
422 ));
423 }
424
425 let key_code = helpers::generate_key_code();
426 let max_activations = req.max_activations.or(item.default_max_activations);
427
428 // Cap at 1000 manually-generated keys per item, enforced atomically under an
429 // item-row lock so concurrent generates can't race past it (fuzz 2026-07-06 C6-1).
430 let Some(key) = db::license_keys::create_manual_key_capped(
431 &db,
432 item_id,
433 user.id,
434 &key_code,
435 max_activations,
436 1000,
437 )
438 .await?
439 else {
440 return Err(AppError::BadRequest(
441 "Maximum of 1000 license keys per item reached".to_string(),
442 ));
443 };
444
445 if is_htmx_request(&headers) {
446 let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?;
447 return Ok(ItemLicenseKeysTemplate {
448 license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(),
449 }
450 .into_response());
451 }
452
453 Ok(Json(GenerateKeyResponse {
454 id: key.id,
455 key_code: key.key_code,
456 max_activations: key.max_activations,
457 created_at: key.created_at,
458 })
459 .into_response())
460 }
461
462 /// List all license keys for an item (creator dashboard).
463 #[tracing::instrument(skip_all, name = "license_keys::list_keys")]
464 pub(super) async fn list_keys(
465 State(db): State<PgPool>,
466 headers: HeaderMap,
467 AuthUser(user): AuthUser,
468 Path(item_id): Path<ItemId>,
469 ) -> Result<Response> {
470 verify_item_ownership(&db, item_id, user.id).await?;
471
472 let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?;
473
474 if is_htmx_request(&headers) {
475 return Ok(ItemLicenseKeysTemplate {
476 license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(),
477 }
478 .into_response());
479 }
480
481 let data: Vec<GenerateKeyResponse> = keys
482 .into_iter()
483 .map(|k| GenerateKeyResponse {
484 id: k.id,
485 key_code: k.key_code,
486 max_activations: k.max_activations,
487 created_at: k.created_at,
488 })
489 .collect();
490
491 Ok(Json(ListResponse { data }).into_response())
492 }
493
494 /// Revoke a license key (creator dashboard).
495 #[tracing::instrument(skip_all, name = "license_keys::revoke_key")]
496 pub(super) async fn revoke_key(
497 State(db): State<PgPool>,
498 headers: HeaderMap,
499 AuthUser(user): AuthUser,
500 Path(key_id): Path<LicenseKeyId>,
501 ) -> Result<Response> {
502 user.check_not_suspended()?;
503
504 let key = db::license_keys::get_license_key_by_id_unchecked(&db, key_id)
505 .await?
506 .ok_or(AppError::NotFound)?;
507
508 verify_item_ownership(&db, key.item_id, user.id).await?;
509
510 db::license_keys::revoke_license_key(&db, key_id).await?;
511
512 if is_htmx_request(&headers) {
513 let keys = db::license_keys::get_license_keys_by_item(&db, key.item_id).await?;
514 return Ok((
515 [("HX-Trigger", hx_toast("License key revoked", "success"))],
516 ItemLicenseKeysTemplate {
517 license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(),
518 },
519 )
520 .into_response());
521 }
522
523 Ok(StatusCode::NO_CONTENT.into_response())
524 }
525
526 // License Verification API, phone-home activation binding
527
528 /// JWT claims for offline license verification.
529 #[derive(Debug, Serialize, Deserialize)]
530 struct LicenseVerifyClaims {
531 /// License key ID
532 sub: String,
533 /// Machine fingerprint
534 machine: String,
535 /// Item ID
536 item: String,
537 /// Issued at (Unix timestamp)
538 iat: i64,
539 /// Expiration (Unix timestamp); 7-day offline grace period
540 exp: i64,
541 }
542
543 /// JSON response for license verification.
544 #[derive(Debug, Serialize, utoipa::ToSchema)]
545 pub(crate) struct LicenseVerifyResponse {
546 valid: bool,
547 #[serde(skip_serializing_if = "Option::is_none")]
548 token: Option<String>,
549 #[serde(skip_serializing_if = "Option::is_none")]
550 expires_in: Option<i64>,
551 #[serde(skip_serializing_if = "Option::is_none")]
552 error: Option<&'static str>,
553 }
554
555 /// JSON input for license verification (phone-home).
556 #[derive(Debug, Deserialize, utoipa::ToSchema)]
557 pub(crate) struct LicenseVerifyRequest {
558 #[schema(value_type = String)]
559 pub key: KeyCode,
560 pub machine_fingerprint: String,
561 }
562
563 /// 7-day offline grace period for signed JWTs.
564 const LICENSE_VERIFY_EXPIRY_SECS: i64 = 7 * 24 * 3600;
565
566 /// Verify a license key and bind it to a machine fingerprint.
567 ///
568 /// If the project has `license_verification_enabled`, validates the key,
569 /// checks/creates an activation (using machine_fingerprint as machine_id),
570 /// and returns a signed JWT for offline verification (valid 7 days).
571 #[utoipa::path(
572 post,
573 path = "/api/v1/license/verify",
574 tag = "License Keys",
575 request_body = LicenseVerifyRequest,
576 responses(
577 (status = 200, description = "Verification result with optional offline JWT", body = LicenseVerifyResponse),
578 ),
579 )]
580 #[tracing::instrument(skip_all, name = "license_keys::license_verify")]
581 pub(super) async fn license_verify(
582 State(db): State<PgPool>,
583 State(config): State<Config>,
584 Json(req): Json<LicenseVerifyRequest>,
585 ) -> Result<impl IntoResponse> {
586 validation::validate_machine_id(&req.machine_fingerprint)?;
587
588 let Some(key) = db::license_keys::get_license_key_by_code(&db, &req.key).await? else {
589 return Ok(Json(LicenseVerifyResponse {
590 valid: false,
591 token: None,
592 expires_in: None,
593 error: Some("invalid_key"),
594 }));
595 };
596
597 if key.revoked_at.is_some() {
598 return Ok(Json(LicenseVerifyResponse {
599 valid: false,
600 token: None,
601 expires_in: None,
602 error: Some("key_revoked"),
603 }));
604 }
605
606 // Check if the project has license verification enabled
607 let item = db::items::get_item_by_id(&db, key.item_id)
608 .await?
609 .ok_or(AppError::NotFound)?;
610 let project = db::projects::get_project_by_id(&db, item.project_id)
611 .await?
612 .ok_or(AppError::NotFound)?;
613
614 if !project.license_verification_enabled {
615 return Ok(Json(LicenseVerifyResponse {
616 valid: false,
617 token: None,
618 expires_in: None,
619 error: Some("verification_not_enabled"),
620 }));
621 }
622
623 // Try to activate (re-activation on same machine is always OK)
624 if let Some(activation) =
625 db::license_keys::get_activation(&db, key.id, &req.machine_fingerprint).await?
626 {
627 if activation.is_active {
628 db::license_keys::touch_activation(&db, activation.id).await?;
629 } else {
630 // Re-activate a previously deactivated machine
631 let reactivated = db::license_keys::try_create_activation(
632 &db,
633 key.id,
634 &req.machine_fingerprint,
635 None,
636 )
637 .await?;
638 if reactivated.is_none() {
639 return Ok(Json(LicenseVerifyResponse {
640 valid: false,
641 token: None,
642 expires_in: None,
643 error: Some("activation_limit_reached"),
644 }));
645 }
646 }
647 } else {
648 // New activation
649 let activation =
650 db::license_keys::try_create_activation(&db, key.id, &req.machine_fingerprint, None)
651 .await?;
652 if activation.is_none() {
653 return Ok(Json(LicenseVerifyResponse {
654 valid: false,
655 token: None,
656 expires_in: None,
657 error: Some("activation_limit_reached"),
658 }));
659 }
660 }
661
662 // Issue a signed JWT for offline grace period
663 let now = chrono::Utc::now().timestamp();
664 let claims = LicenseVerifyClaims {
665 sub: key.id.to_string(),
666 machine: req.machine_fingerprint,
667 item: key.item_id.to_string(),
668 iat: now,
669 exp: now + LICENSE_VERIFY_EXPIRY_SECS,
670 };
671
672 let token = encode(
673 &Header::default(),
674 &claims,
675 &EncodingKey::from_secret(config.signing_secret.as_bytes()),
676 )
677 .context("jwt encode")?;
678
679 Ok(Json(LicenseVerifyResponse {
680 valid: true,
681 token: Some(token),
682 expires_in: Some(LICENSE_VERIFY_EXPIRY_SECS),
683 error: None,
684 }))
685 }
686
687 /// JSON input for license deactivation (phone-home).
688 #[derive(Debug, Deserialize, utoipa::ToSchema)]
689 pub(crate) struct LicenseDeactivateRequest {
690 #[schema(value_type = String)]
691 pub key: KeyCode,
692 pub machine_fingerprint: String,
693 }
694
695 /// Deactivate a license on a specific machine (free up a slot).
696 #[utoipa::path(
697 post,
698 path = "/api/v1/license/deactivate",
699 tag = "License Keys",
700 request_body = LicenseDeactivateRequest,
701 responses(
702 (status = 200, description = "Deactivation result", body = DeactivateKeyResponse),
703 ),
704 )]
705 #[tracing::instrument(skip_all, name = "license_keys::license_deactivate")]
706 pub(super) async fn license_deactivate(
707 State(db): State<PgPool>,
708 Json(req): Json<LicenseDeactivateRequest>,
709 ) -> Result<impl IntoResponse> {
710 validation::validate_machine_id(&req.machine_fingerprint)?;
711
712 let Some(key) = db::license_keys::get_license_key_by_code(&db, &req.key).await? else {
713 return Ok(Json(DeactivateKeyResponse {
714 success: false,
715 message: "Invalid key",
716 }));
717 };
718
719 let deactivated =
720 db::license_keys::deactivate_machine(&db, key.id, &req.machine_fingerprint).await?;
721
722 Ok(Json(DeactivateKeyResponse {
723 success: deactivated,
724 message: if deactivated {
725 "Machine deactivated"
726 } else {
727 "No active activation found"
728 },
729 }))
730 }
731
732 // License Text, public endpoint
733
734 /// Serve rendered license text for an item as plain text.
735 #[utoipa::path(
736 get,
737 path = "/api/v1/items/{item_id}/license.txt",
738 tag = "License Keys",
739 params(("item_id" = String, Path, description = "The item ID")),
740 responses(
741 (status = 200, description = "License text", content_type = "text/plain"),
742 (status = 404, description = "Item not found or no license configured"),
743 ),
744 )]
745 #[tracing::instrument(skip_all, name = "license_keys::license_text")]
746 pub(super) async fn license_text(
747 State(db): State<PgPool>,
748 Path(item_id): Path<ItemId>,
749 ) -> Result<Response> {
750 use crate::license_templates::{LicensePreset, render_license_text};
751
752 let item = db::items::get_item_by_id(&db, item_id)
753 .await?
754 .ok_or(AppError::NotFound)?;
755
756 let preset_str = item.license_preset.as_deref().ok_or(AppError::NotFound)?;
757 let preset: LicensePreset = preset_str.parse().map_err(|_| AppError::NotFound)?;
758
759 // Get owner display name
760 let project = db::projects::get_project_by_id(&db, item.project_id)
761 .await?
762 .ok_or(AppError::NotFound)?;
763 let user = db::users::get_user_by_id(&db, project.user_id)
764 .await?
765 .ok_or(AppError::NotFound)?;
766
767 // Visibility gate: this endpoint is unauthenticated, so a draft/deleted item
768 // (or a sandbox account's) must not leak its license text or owner name to
769 // anyone who guesses the item UUID (Run 21 security).
770 if !item.is_public || item.deleted_at.is_some() || user.is_sandbox {
771 return Err(AppError::NotFound);
772 }
773
774 let owner = user.display_name.as_deref().unwrap_or(&user.username);
775 let year = chrono::Utc::now().year();
776
777 let text = render_license_text(preset, owner, year, item.custom_license_text.as_deref());
778
779 Ok((
780 StatusCode::OK,
781 [(
782 axum::http::header::CONTENT_TYPE,
783 "text/plain; charset=utf-8",
784 )],
785 text,
786 )
787 .into_response())
788 }
789