feat(app-catalog): serve the signed catalog from the node first

This commit is contained in:
archipelago
2026-08-31 16:15:13 -04:00
parent 21b8d4b1ee
commit 5b658cec67
2 changed files with 33 additions and 0 deletions
+13
View File
@@ -145,6 +145,19 @@ impl ApiHandler {
/// URL so the App Store still renders on nodes that haven't persisted
/// a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
// The daemon already refreshes and verifies releases/app-catalog.json.
// Serve that release-root-anchored cache first so a newly published app
// appears immediately, without a frontend release. The old external UI
// catalog below is emergency compatibility only; it must never override
// a healthy signed catalog (Cuprate was invisible for exactly that reason).
if let Ok(body) = crate::container::app_catalog::verified_catalog_body(&self.config.data_dir).await {
return Ok(Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-cache")
.body(hyper::Body::from(body))?);
}
let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
{
@@ -24,6 +24,7 @@
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
//! publisher side never breaks older nodes.
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -194,6 +195,25 @@ fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
load_catalog().apps.get(app_id).cloned()
}
/// Return the cached catalog bytes only when they carry a signature anchored
/// to the release root. This is the browser App Store's source: newly signed
/// apps must appear without waiting for a frontend OTA, while unsigned or
/// self-signed registry data must never become an install button.
pub async fn verified_catalog_body(data_dir: &Path) -> anyhow::Result<String> {
let path = data_dir.join(APP_CATALOG_FILE);
let body = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("read signed app catalog {}", path.display()))?;
let raw: serde_json::Value = serde_json::from_str(&body)?;
match crate::trust::verify_detached(&raw)? {
crate::trust::SignatureStatus::Verified { anchored: true, .. } => Ok(body),
crate::trust::SignatureStatus::Verified { anchored: false, .. } => {
anyhow::bail!("app catalog signer is not anchored to the release root")
}
crate::trust::SignatureStatus::Unsigned => anyhow::bail!("app catalog is unsigned"),
}
}
/// Primary image for an app per the remote catalog, if covered.
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
entry_for(app_id).and_then(|e| e.image)