From 5b658cec67634f7201c172997bb5379e50f861f9 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 31 Aug 2026 16:15:13 -0400 Subject: [PATCH] feat(app-catalog): serve the signed catalog from the node first --- core/archipelago/src/api/handler/mod.rs | 13 ++++++++++++ core/archipelago/src/container/app_catalog.rs | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 99a85893..8df3d57d 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -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> { + // 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 = Vec::new(); if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await { diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index ff0f1dd9..fd960871 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -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 { 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 { + 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 { entry_for(app_id).and_then(|e| e.image)