1use axum::{
4 extract::{Path, State},
5 response::Redirect,
6 Json,
7};
8use mockforge_plugin_registry::{
9 AuthorInfo, PluginCategory, RegistryEntry, SearchQuery, SearchResults, SortOrder, VersionEntry,
10};
11use serde::{Deserialize, Serialize};
12
13use crate::{
14 error::{ApiError, ApiResult},
15 middleware::{AuthUser, ScopedAuth},
16 models::{AuditEventType, TokenScope, User},
17 AppState,
18};
19
20fn tracked_download_url(name: &str, version: &str) -> String {
23 format!(
24 "/api/v1/plugins/{}/versions/{}/download",
25 urlencoding::encode(name),
26 urlencoding::encode(version)
27 )
28}
29
30pub async fn search_plugins(
31 State(state): State<AppState>,
32 Json(query): Json<SearchQuery>,
33) -> ApiResult<Json<SearchResults>> {
34 let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "plugin");
35 let pool = state.db.pool();
36
37 let sort_by = match query.sort {
39 SortOrder::Downloads => "downloads",
40 SortOrder::Rating => "rating",
41 SortOrder::Recent => "recent",
42 SortOrder::Name => "name",
43 SortOrder::Popular => "popular",
44 SortOrder::Security => "security",
45 _ => "downloads",
46 };
47
48 let category_str = query.category.as_ref().map(|c| match c {
50 PluginCategory::Auth => "auth",
51 PluginCategory::Template => "template",
52 PluginCategory::Response => "response",
53 PluginCategory::DataSource => "datasource",
54 PluginCategory::Middleware => "middleware",
55 PluginCategory::Testing => "testing",
56 PluginCategory::Observability => "observability",
57 PluginCategory::Other => "other",
58 });
59
60 let language_filter = query.language.as_deref().and_then(|l| {
64 let trimmed = l.trim();
65 if trimmed.is_empty() {
66 None
67 } else {
68 Some(trimmed.to_ascii_lowercase())
69 }
70 });
71
72 let per_page = query.per_page.clamp(1, 100); let page = query.page;
75 let limit = per_page as i64;
76 let offset = (page * per_page) as i64;
77
78 let plugins = match state
80 .store
81 .search_plugins(
82 query.query.as_deref(),
83 category_str,
84 language_filter.as_deref(),
85 &query.tags,
86 sort_by,
87 limit,
88 offset,
89 )
90 .await
91 {
92 Ok(plugins) => plugins,
93 Err(e) => {
94 metrics.record_search_error("database_error");
95 return Err(e.into());
96 }
97 };
98
99 let mut entries = Vec::new();
101 for plugin in plugins {
102 let tags = state.store.get_plugin_tags(plugin.id).await?;
103
104 let versions = state.store.list_plugin_versions(plugin.id).await?;
105
106 let category = map_category_from_string(&plugin.category);
107
108 let mut version_entries = Vec::new();
114 for v in versions {
115 let dependencies = state.store.get_plugin_version_dependencies(v.id).await?;
116
117 let tracked_url = tracked_download_url(&plugin.name, &v.version);
118 version_entries.push(VersionEntry {
119 version: v.version,
120 download_url: tracked_url,
121 checksum: v.checksum,
122 size: v.file_size as u64,
123 published_at: v.published_at.to_rfc3339(),
124 yanked: v.yanked,
125 min_mockforge_version: v.min_mockforge_version,
126 dependencies,
127 downloads: v.downloads.max(0) as u64,
128 });
129 }
130
131 let author = User::find_by_id(pool, plugin.author_id)
133 .await
134 .map_err(ApiError::Database)?
135 .unwrap_or_else(|| User::placeholder(plugin.author_id));
136
137 let security_score = derive_security_score(&plugin);
138 let language = plugin.language.clone();
139 entries.push(RegistryEntry {
140 name: plugin.name.clone(),
141 description: plugin.description.clone(),
142 version: plugin.current_version.clone(),
143 versions: version_entries,
144 author: AuthorInfo {
145 name: author.username,
146 email: Some(author.email),
147 url: None,
148 },
149 tags,
150 category,
151 downloads: plugin.downloads_total as u64,
152 rating: decimal_to_f64(plugin.rating_avg),
153 reviews_count: plugin.rating_count as u32,
154 security_score,
155 language,
156 repository: plugin.repository,
157 homepage: plugin.homepage,
158 license: plugin.license,
159 created_at: plugin.created_at.to_rfc3339(),
160 updated_at: plugin.updated_at.to_rfc3339(),
161 });
162 }
163
164 let total = state
166 .store
167 .count_search_plugins(
168 query.query.as_deref(),
169 category_str,
170 language_filter.as_deref(),
171 &query.tags,
172 )
173 .await? as usize;
174
175 let results = SearchResults {
176 plugins: entries,
177 total,
178 page,
179 per_page,
180 };
181
182 metrics.record_search_success();
184
185 Ok(Json(results))
186}
187
188pub async fn get_plugin(
189 State(state): State<AppState>,
190 Path(name): Path<String>,
191) -> ApiResult<Json<RegistryEntry>> {
192 let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "plugin");
193
194 let plugin = state
195 .store
196 .find_plugin_by_name(&name)
197 .await?
198 .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
199
200 if plugin.taken_down_at.is_some() {
203 return Err(ApiError::PluginNotFound(name));
204 }
205
206 let tags = state.store.get_plugin_tags(plugin.id).await?;
207
208 let versions = state.store.list_plugin_versions(plugin.id).await?;
209
210 let category = map_category_from_string(&plugin.category);
211
212 let mut version_entries = Vec::new();
215 for v in versions {
216 let dependencies = state.store.get_plugin_version_dependencies(v.id).await?;
217
218 let tracked_url = tracked_download_url(&plugin.name, &v.version);
219 version_entries.push(VersionEntry {
220 version: v.version,
221 download_url: tracked_url,
222 checksum: v.checksum,
223 size: v.file_size as u64,
224 published_at: v.published_at.to_rfc3339(),
225 yanked: v.yanked,
226 min_mockforge_version: v.min_mockforge_version,
227 dependencies,
228 downloads: v.downloads.max(0) as u64,
229 });
230 }
231
232 let author = state
234 .store
235 .find_user_by_id(plugin.author_id)
236 .await?
237 .unwrap_or_else(|| User::placeholder(plugin.author_id));
238
239 let security_score = derive_security_score(&plugin);
240 let language = plugin.language.clone();
241 let entry = RegistryEntry {
242 name: plugin.name.clone(),
243 description: plugin.description.clone(),
244 version: plugin.current_version.clone(),
245 versions: version_entries,
246 author: AuthorInfo {
247 name: author.username,
248 email: Some(author.email),
249 url: None,
250 },
251 tags,
252 category,
253 downloads: plugin.downloads_total as u64,
254 rating: decimal_to_f64(plugin.rating_avg),
255 reviews_count: plugin.rating_count as u32,
256 security_score,
257 language,
258 repository: plugin.repository,
259 homepage: plugin.homepage,
260 license: plugin.license,
261 created_at: plugin.created_at.to_rfc3339(),
262 updated_at: plugin.updated_at.to_rfc3339(),
263 };
264
265 metrics.record_download_success();
267
268 Ok(Json(entry))
269}
270
271pub async fn get_version(
272 State(state): State<AppState>,
273 Path((name, version)): Path<(String, String)>,
274) -> ApiResult<Json<VersionEntry>> {
275 let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "plugin");
276
277 let plugin = state
278 .store
279 .find_plugin_by_name(&name)
280 .await?
281 .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
282
283 if plugin.taken_down_at.is_some() {
284 return Err(ApiError::PluginNotFound(name));
285 }
286
287 let plugin_version = state
288 .store
289 .find_plugin_version(plugin.id, &version)
290 .await?
291 .ok_or_else(|| ApiError::InvalidVersion(version.clone()))?;
292
293 let dependencies = state.store.get_plugin_version_dependencies(plugin_version.id).await?;
295
296 let tracked_url = tracked_download_url(&name, &plugin_version.version);
297 let entry = VersionEntry {
298 version: plugin_version.version,
299 download_url: tracked_url,
300 checksum: plugin_version.checksum,
301 size: plugin_version.file_size as u64,
302 published_at: plugin_version.published_at.to_rfc3339(),
303 yanked: plugin_version.yanked,
304 min_mockforge_version: plugin_version.min_mockforge_version,
305 dependencies,
306 downloads: plugin_version.downloads.max(0) as u64,
307 };
308
309 metrics.record_download_success();
311
312 Ok(Json(entry))
313}
314
315pub async fn download_version(
321 State(state): State<AppState>,
322 Path((name, version)): Path<(String, String)>,
323) -> ApiResult<Redirect> {
324 let plugin = state
325 .store
326 .find_plugin_by_name(&name)
327 .await?
328 .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
329
330 if plugin.taken_down_at.is_some() {
331 return Err(ApiError::PluginNotFound(name));
332 }
333
334 let plugin_version = state
335 .store
336 .find_plugin_version(plugin.id, &version)
337 .await?
338 .ok_or_else(|| ApiError::InvalidVersion(version.clone()))?;
339
340 if let Err(e) = state.store.increment_plugin_download(plugin.id, plugin_version.id).await {
344 tracing::warn!(plugin = %name, version = %version, error = %e, "increment_plugin_download failed");
345 }
346
347 Ok(Redirect::temporary(&plugin_version.download_url))
348}
349
350#[derive(Debug, Deserialize)]
351pub struct PublishRequest {
352 pub name: String,
353 pub version: String,
354 pub description: String,
355 pub category: String,
356 pub license: String,
357 pub repository: Option<String>,
358 pub homepage: Option<String>,
359 pub tags: Vec<String>,
360 pub checksum: String,
361 #[serde(alias = "fileSize")]
362 pub file_size: i64,
363 #[serde(alias = "wasmData")]
364 pub wasm_data: String, pub dependencies: Option<std::collections::HashMap<String, String>>,
366 #[serde(default = "default_plugin_language")]
368 pub language: String,
369 #[serde(default)]
373 pub sbom: Option<serde_json::Value>,
374
375 #[serde(default, alias = "sbomSignature")]
382 pub sbom_signature: Option<String>,
383}
384
385fn default_plugin_language() -> String {
386 "rust".to_string()
387}
388
389#[derive(Debug, Serialize)]
390#[serde(rename_all = "camelCase")]
391pub struct PublishResponse {
392 pub success: bool,
393 pub upload_url: String,
394 pub message: String,
395}
396
397pub async fn publish_plugin(
398 AuthUser(author_id): AuthUser,
399 scoped_auth: ScopedAuth,
400 State(state): State<AppState>,
401 Json(request): Json<PublishRequest>,
402) -> ApiResult<Json<PublishResponse>> {
403 scoped_auth.require_scope(TokenScope::PublishPackages)?;
405
406 let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "plugin");
407
408 let existing = state.store.find_plugin_by_name(&request.name).await?;
410
411 let plugin = if let Some(mut plugin) = existing {
412 plugin.current_version = request.version.clone();
414 plugin.description = request.description.clone();
415 plugin
416 } else {
417 state
419 .store
420 .create_plugin(
421 &request.name,
422 &request.description,
423 &request.version,
424 &request.category,
425 &request.license,
426 request.repository.as_deref(),
427 request.homepage.as_deref(),
428 author_id,
429 &request.language,
430 )
431 .await?
432 };
433
434 crate::validation::validate_name(&request.name)?;
436 crate::validation::validate_version(&request.version)?;
437 crate::validation::validate_checksum(&request.checksum)?;
438
439 crate::validation::validate_base64(&request.wasm_data)?;
441
442 use base64::Engine;
444 let wasm_bytes = base64::engine::general_purpose::STANDARD
445 .decode(&request.wasm_data)
446 .map_err(|e| ApiError::InvalidRequest(format!("Invalid base64: {}", e)))?;
447
448 crate::validation::validate_wasm_file(&wasm_bytes, request.file_size as u64)?;
450
451 use sha2::{Digest, Sha256};
453 let mut hasher = Sha256::new();
454 hasher.update(&wasm_bytes);
455 let calculated_checksum = hex::encode(hasher.finalize());
456
457 if calculated_checksum != request.checksum {
458 return Err(ApiError::InvalidRequest(format!(
459 "Checksum mismatch: expected {}, got {}",
460 request.checksum, calculated_checksum
461 )));
462 }
463
464 let download_url = state
466 .storage
467 .upload_plugin(&request.name, &request.version, wasm_bytes)
468 .await
469 .map_err(|e| ApiError::Storage(e.to_string()))?;
470
471 let version = state
473 .store
474 .create_plugin_version(
475 plugin.id,
476 &request.version,
477 &download_url,
478 &request.checksum,
479 request.file_size,
480 None,
481 request.sbom.as_ref(),
482 )
483 .await?;
484
485 if let Some(deps) = request.dependencies {
487 for (dep_name, dep_version) in deps {
488 state
489 .store
490 .add_plugin_version_dependency(version.id, &dep_name, &dep_version)
491 .await?;
492 }
493 }
494
495 if let (Some(sbom_json), Some(signature)) =
500 (request.sbom.as_ref(), request.sbom_signature.as_deref())
501 {
502 use mockforge_registry_core::models::attestation::{
503 verify_sbom_attestation, SbomAttestationInput, SbomVerifyOutcome,
504 };
505
506 let keys = state.store.list_keys_for_publisher(author_id).await?;
511 let canonical = serde_jcs::to_vec(sbom_json)
517 .map_err(|e| ApiError::InvalidRequest(format!("canonicalizing SBOM: {}", e)))?;
518 let outcome = verify_sbom_attestation(
519 &keys,
520 &SbomAttestationInput {
521 artifact_checksum: &request.checksum,
522 sbom_canonical: &canonical,
523 signature_b64: signature,
524 },
525 );
526 match outcome {
527 SbomVerifyOutcome::Verified { key_id } => {
528 state.store.record_plugin_version_attestation(version.id, Some(key_id)).await?;
529 }
530 SbomVerifyOutcome::NoKeys => {
531 return Err(ApiError::InvalidRequest(
532 "sbom_signature supplied but no public keys are registered on this account"
533 .to_string(),
534 ));
535 }
536 SbomVerifyOutcome::Invalid => {
537 return Err(ApiError::InvalidRequest(
538 "sbom_signature did not verify against any registered key".to_string(),
539 ));
540 }
541 SbomVerifyOutcome::Malformed(reason) => {
542 return Err(ApiError::InvalidRequest(format!(
543 "sbom_signature is malformed: {}",
544 reason
545 )));
546 }
547 }
548 }
549
550 let pending_findings = serde_json::json!([
556 {
557 "severity": "info",
558 "category": "other",
559 "title": "Automated scan pending",
560 "description": "This plugin version is queued for automated security scanning. Results usually appear within a minute."
561 }
562 ]);
563 state
564 .store
565 .upsert_plugin_security_scan(version.id, "pending", 50, &pending_findings, None)
566 .await?;
567
568 state
570 .store
571 .record_audit_event(
572 uuid::Uuid::nil(),
573 Some(author_id),
574 AuditEventType::PluginPublished,
575 format!("Plugin {} version {} published", request.name, request.version),
576 Some(serde_json::json!({
577 "plugin_name": request.name,
578 "version": request.version,
579 "category": request.category,
580 })),
581 None,
582 None,
583 )
584 .await;
585
586 metrics.record_publish_success();
588
589 Ok(Json(PublishResponse {
590 success: true,
591 upload_url: download_url.clone(),
592 message: format!(
593 "Plugin {} version {} published successfully",
594 request.name, request.version
595 ),
596 }))
597}
598
599pub async fn yank_version(
600 scoped_auth: ScopedAuth,
601 State(state): State<AppState>,
602 Path((name, version)): Path<(String, String)>,
603) -> ApiResult<Json<serde_json::Value>> {
604 scoped_auth.require_scope(TokenScope::PublishPackages)?;
606
607 let plugin = state
608 .store
609 .find_plugin_by_name(&name)
610 .await?
611 .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
612
613 let plugin_version = state
614 .store
615 .find_plugin_version(plugin.id, &version)
616 .await?
617 .ok_or_else(|| ApiError::InvalidVersion(version.clone()))?;
618
619 state.store.yank_plugin_version(plugin_version.id).await?;
620
621 Ok(Json(serde_json::json!({
622 "success": true,
623 "message": format!("Version {} of {} yanked successfully", version, name)
624 })))
625}
626
627fn derive_security_score(plugin: &crate::models::Plugin) -> u8 {
634 let mut score: i32 = 50;
635 if plugin.verified_at.is_some() {
636 score += 35;
637 }
638 let ninety_days_ago = chrono::Utc::now() - chrono::Duration::days(90);
639 if plugin.updated_at > ninety_days_ago {
640 score += 5;
641 }
642 if plugin.rating_avg >= rust_decimal::Decimal::new(40, 1) && plugin.rating_count >= 5 {
643 score += 5;
644 }
645 score.clamp(0, 100) as u8
646}
647
648fn decimal_to_f64(d: rust_decimal::Decimal) -> f64 {
653 use rust_decimal::prelude::ToPrimitive;
654 d.to_f64().unwrap_or(0.0)
655}
656
657#[derive(Debug, Serialize)]
658#[serde(rename_all = "camelCase")]
659pub struct SecurityScanResponse {
660 pub status: String,
661 pub score: u8,
662 pub findings: Vec<SecurityFindingDto>,
663 pub scanned: bool,
664}
665
666#[derive(Debug, Serialize)]
667#[serde(rename_all = "camelCase")]
668pub struct SecurityFindingDto {
669 pub severity: String,
670 pub category: String,
671 pub title: String,
672 pub description: String,
673}
674
675pub async fn get_plugin_security(
676 State(state): State<AppState>,
677 Path(name): Path<String>,
678) -> ApiResult<Json<SecurityScanResponse>> {
679 let plugin = state
680 .store
681 .find_plugin_by_name(&name)
682 .await?
683 .ok_or_else(|| ApiError::PluginNotFound(name.clone()))?;
684
685 if let Some(scan) = state.store.latest_security_scan_for_plugin(plugin.id).await? {
689 let status = match scan.status.as_str() {
690 "pass" | "warning" | "fail" | "pending" => scan.status.clone(),
691 _ => "pending".to_string(),
692 };
693 let findings: Vec<serde_json::Value> =
694 serde_json::from_value(scan.findings).unwrap_or_default();
695 let finding_dtos = findings
696 .into_iter()
697 .filter_map(|f| {
698 Some(SecurityFindingDto {
699 severity: map_severity(f.get("severity")?.as_str()?).to_string(),
700 category: map_finding_category(f.get("category")?.as_str()?).to_string(),
701 title: f.get("title")?.as_str()?.to_string(),
702 description: f.get("description")?.as_str()?.to_string(),
703 })
704 })
705 .collect();
706 let scanned = scan.status != "pending";
707 return Ok(Json(SecurityScanResponse {
708 status,
709 score: scan.score.clamp(0, 100) as u8,
710 findings: finding_dtos,
711 scanned,
712 }));
713 }
714
715 let score = derive_security_score(&plugin);
717 let status = if score >= 70 {
718 "pass"
719 } else if score >= 50 {
720 "warning"
721 } else {
722 "fail"
723 };
724 let findings = if plugin.verified_at.is_some() {
725 Vec::new()
726 } else {
727 vec![SecurityFindingDto {
728 severity: "info".to_string(),
729 category: "other".to_string(),
730 title: "Automated scan pending".to_string(),
731 description:
732 "This plugin has not yet been processed by the security scanner. The score shown is a heuristic based on verification status and activity."
733 .to_string(),
734 }]
735 };
736
737 Ok(Json(SecurityScanResponse {
738 status: status.to_string(),
739 score,
740 findings,
741 scanned: false,
742 }))
743}
744
745fn map_severity(s: &str) -> &'static str {
746 match s.to_ascii_lowercase().as_str() {
747 "critical" => "critical",
748 "high" => "high",
749 "medium" => "medium",
750 "low" => "low",
751 _ => "info",
752 }
753}
754
755fn map_finding_category(s: &str) -> &'static str {
756 match s.to_ascii_lowercase().as_str() {
757 "malware" => "malware",
758 "vulnerable_dependency" | "vulnerabledependency" => "vulnerable_dependency",
759 "insecure_coding" | "insecurecoding" => "insecure_coding",
760 "data_exfiltration" | "dataexfiltration" => "data_exfiltration",
761 "supply_chain" | "supplychain" => "supply_chain",
762 "licensing" => "licensing",
763 "configuration" => "configuration",
764 "obfuscation" => "obfuscation",
765 _ => "other",
766 }
767}
768
769fn map_category_from_string(cat: &str) -> PluginCategory {
770 match cat {
771 "auth" => PluginCategory::Auth,
772 "template" => PluginCategory::Template,
773 "response" => PluginCategory::Response,
774 "datasource" => PluginCategory::DataSource,
775 "middleware" => PluginCategory::Middleware,
776 "testing" => PluginCategory::Testing,
777 "observability" => PluginCategory::Observability,
778 _ => PluginCategory::Other,
779 }
780}