diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/download.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/download.rs
new file mode 100644
--- /dev/null
+++ b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/download.rs
@@ -0,0 +1,20 @@
+use serde::{Deserialize, Serialize};
+use utoipa::{IntoParams, ToSchema};
+
+/// Query parameters for downloading a Craft App release asset.
+#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema, IntoParams)]
+pub struct CraftAppDownloadQuery {
+  /// Target CPU architecture, e.g. "x86_64", "x64", "aarch64", "arm64", "universal".
+  pub arch: Option<String>,
+  /// Target operating system, e.g. "linux", "windows", "macos", "freebsd", "web".
+  pub os: Option<String>,
+}
+
+/// Error response returned when an application or asset cannot be found.
+#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
+pub struct CraftAppDownloadErrorResponse {
+  pub success: bool,
+  pub error: String,
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub available_assets: Option<Vec<String>>,
+}
diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/flatpak.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/flatpak.rs
new file mode 100644
--- /dev/null
+++ b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/flatpak.rs
@@ -0,0 +1,291 @@
+//! Flatpak repository configuration and .flatpakrepo metadata definitions for ArtCraft apps.
+//!
+//! Provides structured models and `.flatpakrepo` file generation for both the overarching
+//! ArtCraft Suite repository and individual Craft application remotes.
+
+use serde_derive::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+pub const DEFAULT_FLATPAK_BRANCH: &str = "stable";
+pub const DEFAULT_FLATPAK_ID_PREFIX: &str = "ai.storyteller.";
+
+/// Configuration defining a Flatpak OSTree repository remote.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+pub struct CraftAppFlatpakRepoConfig {
+  /// The app key (e.g. `photocraft`), or `artcraft` for the complete suite repository.
+  pub app_key: String,
+
+  /// Flatpak Application ID (e.g. `ai.storyteller.photocraft`).
+  pub app_id: String,
+
+  /// Display title for the repository remote (e.g. `PhotoCraft` or `ArtCraft`).
+  pub title: String,
+
+  /// Short comment/summary describing the remote.
+  pub comment: String,
+
+  /// Longer description of the repository.
+  pub description: String,
+
+  /// URL of the OSTree repository (where `summary` and `objects/` are fetched).
+  pub repo_url: String,
+
+  /// Public homepage URL.
+  pub homepage: String,
+
+  /// Default OSTree branch (usually `stable`).
+  pub default_branch: String,
+
+  /// Optional GPG verification key URL or base64 key data.
+  pub gpg_key_url: Option<String>,
+}
+
+impl CraftAppFlatpakRepoConfig {
+  /// Generates the standard INI-formatted `.flatpakrepo` file content parsed by Flatpak.
+  pub fn to_flatpakrepo_content(&self) -> String {
+    let mut out = String::with_capacity(512);
+    out.push_str("[Flatpak Repo]\n");
+    out.push_str(&format!("Title={}\n", self.title));
+    out.push_str(&format!("Url={}\n", self.repo_url));
+    out.push_str(&format!("Homepage={}\n", self.homepage));
+    out.push_str(&format!("Comment={}\n", self.comment));
+    out.push_str(&format!("Description={}\n", self.description));
+    out.push_str(&format!("DefaultBranch={}\n", self.default_branch));
+    if let Some(ref gpg_key) = self.gpg_key_url {
+      out.push_str(&format!("GPGKey={}\n", gpg_key));
+    }
+    out
+  }
+
+  /// Builds a repository configuration for the complete ArtCraft suite.
+  pub fn suite_config(repo_url: &str, homepage: &str, gpg_key_url: Option<String>) -> Self {
+    Self { app_key: "artcraft".to_string(), app_id: "ai.storyteller.artcraft".to_string(), title: "ArtCraft".to_string(), comment: "Official ArtCraft Suite releases".to_string(), description: "Official Flatpak repository for ArtCraft suite applications".to_string(), repo_url: repo_url.trim_end_matches('/').to_string() + "/", homepage: homepage.to_string(), default_branch: DEFAULT_FLATPAK_BRANCH.to_string(), gpg_key_url }
+  }
+
+  /// Builds a repository configuration for a single Craft application.
+  pub fn app_config(app_key: &str, display_name: &str, repo_url: &str, homepage: &str, gpg_key_url: Option<String>) -> Self {
+    let clean_key = app_key.trim().to_lowercase();
+    let app_id = format!("{DEFAULT_FLATPAK_ID_PREFIX}{clean_key}");
+    Self { app_key: clean_key.clone(), app_id, title: display_name.to_string(), comment: format!("Official {display_name} releases"), description: format!("Official Flatpak repository for {display_name}"), repo_url: repo_url.trim_end_matches('/').to_string() + "/", homepage: homepage.to_string(), default_branch: DEFAULT_FLATPAK_BRANCH.to_string(), gpg_key_url }
+  }
+}
+
+use std::cmp::Ordering;
+
+/// Metadata advertising Flatpak repository availability in release info responses.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+pub struct CraftAppFlatpakRepoInfo {
+  /// Flatpak Application ID (e.g. `ai.storyteller.photocraft`).
+  pub app_id: String,
+
+  /// Primary repository URL to add as a remote.
+  pub repo_url: String,
+
+  /// Direct URL to download the `.flatpakrepo` configuration file.
+  pub flatpakrepo_url: String,
+
+  /// Default tracking branch (e.g. `stable`).
+  pub branch: String,
+
+  /// Version of the application currently available in this Flatpak repository.
+  ///
+  /// Clients and launchers must verify that this version is strictly greater than
+  /// the installed version before triggering repo-based updates (`flatpak update`)
+  /// or switching remotes, preventing accidental downgrades when the Flatpak repository
+  /// lags behind GitHub releases or installed packages.
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub version: Option<String>,
+
+  /// Minimum installed version required before attempting a repository update or migration.
+  /// If the client has an installed version below `min_version`, or if the repository
+  /// version is older than what is currently installed, the client must skip the update.
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub min_version: Option<String>,
+
+  /// Whether the Flatpak repository is verified to be up-to-date with `latest_release`.
+  /// When `false` or absent while repository version is older, clients should not downgrade.
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub is_synced: Option<bool>,
+
+  /// Direct URL to download the repository GPG verification key.
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub gpg_key_url: Option<String>,
+}
+
+impl CraftAppFlatpakRepoInfo {
+  /// Checks whether updating from `installed_version` to this repository would cause
+  /// an accidental downgrade.
+  ///
+  /// Returns `true` if:
+  /// - `version` is known and strictly older than `installed_version`.
+  /// - `min_version` is specified and `installed_version` is strictly older than `min_version`.
+  ///
+  /// Returns `false` if `version` is newer than or equal to `installed_version`,
+  /// or if versions cannot be compared.
+  pub fn would_downgrade(&self, installed_version: &str) -> bool {
+    if let Some(ref min_v) = self.min_version {
+      if let Some(ord) = compare_versions(installed_version, min_v) {
+        if ord == Ordering::Less {
+          return true;
+        }
+      }
+    }
+
+    if let Some(ref repo_v) = self.version {
+      if let Some(ord) = compare_versions(repo_v, installed_version) {
+        if ord == Ordering::Less {
+          return true;
+        }
+      }
+    }
+
+    false
+  }
+
+  /// Returns `true` if this repository's version is strictly newer than `installed_version`.
+  pub fn is_newer_than(&self, installed_version: &str) -> bool {
+    let Some(ref repo_v) = self.version else {
+      return false;
+    };
+    compare_versions(repo_v, installed_version) == Some(Ordering::Greater)
+  }
+}
+
+/// Parsed representation of a version triplet `(major, minor, patch, Option<prerelease>)`.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SimpleSemver {
+  pub major: u64,
+  pub minor: u64,
+  pub patch: u64,
+  pub prerelease: Option<String>,
+}
+
+impl SimpleSemver {
+  /// Parses a version string like "v1.2.3", "0.5.0-rc.1", "0.4.1", "1.2".
+  pub fn parse(text: &str) -> Option<Self> {
+    let t = text.trim();
+    let t = t.strip_prefix(['v', 'V']).unwrap_or(t);
+    if t.is_empty() {
+      return None;
+    }
+    let (core, pre) = match t.find(['-', '+']) {
+      Some(idx) => (&t[..idx], Some(t[idx + 1..].to_string())),
+      None => (t, None),
+    };
+    let parts: Vec<&str> = core.split('.').collect();
+    let major = parts.first()?.parse::<u64>().ok()?;
+    let minor = parts.get(1).map_or(Ok(0), |p| p.parse::<u64>()).ok()?;
+    let patch = parts.get(2).map_or(Ok(0), |p| p.parse::<u64>()).ok()?;
+    Some(Self { major, minor, patch, prerelease: pre })
+  }
+
+  /// Compares two versions following SemVer precedence rules:
+  /// 1. Compare major, minor, patch numerically.
+  /// 2. If equal, a version with a prerelease is *older* than a version without one.
+  /// 3. If both have a prerelease, compare prerelease strings lexicographically.
+  pub fn compare(&self, other: &Self) -> Ordering {
+    match self.major.cmp(&other.major) {
+      Ordering::Equal => {},
+      non_eq => return non_eq,
+    }
+    match self.minor.cmp(&other.minor) {
+      Ordering::Equal => {},
+      non_eq => return non_eq,
+    }
+    match self.patch.cmp(&other.patch) {
+      Ordering::Equal => {},
+      non_eq => return non_eq,
+    }
+    match (&self.prerelease, &other.prerelease) {
+      (None, None) => Ordering::Equal,
+      (Some(_), None) => Ordering::Less,
+      (None, Some(_)) => Ordering::Greater,
+      (Some(a), Some(b)) => a.cmp(b),
+    }
+  }
+}
+
+/// Compares version string `a` to `b`.
+/// Returns `Some(Ordering)` or `None` if either string cannot be parsed.
+pub fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
+  let v_a = SimpleSemver::parse(a)?;
+  let v_b = SimpleSemver::parse(b)?;
+  Some(v_a.compare(&v_b))
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn serializes_suite_flatpakrepo() {
+    let config = CraftAppFlatpakRepoConfig::suite_config("https://repo.getartcraft.com", "https://getartcraft.com", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));
+    let content = config.to_flatpakrepo_content();
+    assert!(content.contains("[Flatpak Repo]"));
+    assert!(content.contains("Title=ArtCraft"));
+    assert!(content.contains("Url=https://repo.getartcraft.com/"));
+    assert!(content.contains("Homepage=https://getartcraft.com"));
+    assert!(content.contains("DefaultBranch=stable"));
+    assert!(content.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));
+  }
+
+  #[test]
+  fn serializes_app_flatpakrepo_with_gpg() {
+    let config = CraftAppFlatpakRepoConfig::app_config("photocraft", "PhotoCraft", "https://repo.getartcraft.com/photocraft", "https://getartcraft.com/apps/photocraft", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));
+    let content = config.to_flatpakrepo_content();
+    assert!(content.contains("Title=PhotoCraft"));
+    assert!(content.contains("Url=https://repo.getartcraft.com/photocraft/"));
+    assert!(content.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));
+    assert_eq!(config.app_id, "ai.storyteller.photocraft");
+  }
+
+  #[test]
+  fn semver_comparison_and_downgrade_prevention() {
+    assert_eq!(compare_versions("0.5.0", "0.4.0"), Some(Ordering::Greater));
+    assert_eq!(compare_versions("0.4.0", "0.5.0"), Some(Ordering::Less));
+    assert_eq!(compare_versions("v0.5.0", "0.5.0"), Some(Ordering::Equal));
+    assert_eq!(compare_versions("0.5.0", "0.5.0-rc.1"), Some(Ordering::Greater));
+    assert_eq!(compare_versions("0.5.0-rc.1", "0.5.0"), Some(Ordering::Less));
+
+    let repo_lagging = CraftAppFlatpakRepoInfo {
+      app_id: "ai.storyteller.photocraft".to_string(),
+      repo_url: "https://repo.getartcraft.com/photocraft/".to_string(),
+      flatpakrepo_url: "https://api.getartcraft.com/v1/craft_apps/flatpak/photocraft.flatpakrepo".to_string(),
+      branch: "stable".to_string(),
+      version: Some("0.4.0".to_string()),
+      min_version: None,
+      is_synced: Some(false),
+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),
+    };
+
+    // If installed is 0.5.0 and repo is 0.4.0, update would downgrade:
+    assert!(repo_lagging.would_downgrade("0.5.0"));
+    assert!(!repo_lagging.is_newer_than("0.5.0"));
+
+    // If installed is 0.4.0, repo is equal, not a downgrade:
+    assert!(!repo_lagging.would_downgrade("0.4.0"));
+    assert!(!repo_lagging.is_newer_than("0.4.0"));
+
+    // If installed is 0.3.0, repo is newer:
+    assert!(!repo_lagging.would_downgrade("0.3.0"));
+    assert!(repo_lagging.is_newer_than("0.3.0"));
+
+    // Testing min_version constraint:
+    let repo_with_min = CraftAppFlatpakRepoInfo {
+      app_id: "ai.storyteller.cadcraft".to_string(),
+      repo_url: "https://repo.getartcraft.com/cadcraft/".to_string(),
+      flatpakrepo_url: "https://api.getartcraft.com/v1/craft_apps/flatpak/cadcraft.flatpakrepo".to_string(),
+      branch: "stable".to_string(),
+      version: Some("0.6.0".to_string()),
+      min_version: Some("0.5.0".to_string()),
+      is_synced: Some(true),
+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),
+    };
+    // Installed is 0.4.0, which is below min_version 0.5.0:
+    assert!(repo_with_min.would_downgrade("0.4.0"));
+    // Installed is 0.5.0, meets min_version and 0.6.0 is newer:
+    assert!(!repo_with_min.would_downgrade("0.5.0"));
+    assert!(repo_with_min.is_newer_than("0.5.0"));
+  }
+}
+
diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs
--- a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs
+++ b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs
@@ -1 +1,3 @@
+pub mod download;
+pub mod flatpak;
 pub mod release_info;
diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs
--- a/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs
+++ b/crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs
@@ -5,6 +5,8 @@ use serde_derive::{Deserialize, Serialize};
 use url::Url;
 use utoipa::ToSchema;
 
+use crate::craft_apps::flatpak::CraftAppFlatpakRepoInfo;
+
 // ── GET /v1/craft_apps/release_info ──
 //
 // The latest release of every Craft App (PhotoCraft, VectorCraft, …, and ArtCraft Launcher
@@ -27,6 +29,10 @@ pub struct GetCraftAppsReleaseInfoResponse {
   pub repo_renames: Vec<CraftAppRepoRename>,
 
   pub important_notices: CraftAppsImportantNotices,
+
+  /// Flatpak repository configuration for the complete ArtCraft Suite (if configured).
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub suite_flatpak_repo: Option<CraftAppFlatpakRepoInfo>,
 }
 
 #[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
@@ -56,6 +62,10 @@ pub struct CraftAppReleaseInfo {
   /// When this app's release data was last fetched successfully. Absent if it never was; then
   /// `latest_release` says nothing and clients should ask GitHub themselves.
   pub app_last_fetched: Option<DateTime<Utc>>,
+
+  /// Flatpak repository configuration for this application (if available).
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub flatpak_repo: Option<CraftAppFlatpakRepoInfo>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs
--- a/crates/service/web/storyteller_web/src/docs/api_doc.rs
+++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs
@@ -162,6 +162,8 @@ use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*;
 use artcraft_api_defs::moderation::top_spenders::list::*;
 use artcraft_api_defs::moderation::user_spend_events::list::*;
 use artcraft_api_defs::folders::subfolder::*;
+use artcraft_api_defs::craft_apps::download::*;
+use artcraft_api_defs::craft_apps::flatpak::*;
 use artcraft_api_defs::craft_apps::release_info::*;
 use artcraft_api_defs::tags::add_media_file_tags::*;
 use artcraft_api_defs::tags::bulk_add_tags::*;
@@ -737,6 +739,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
 
     // Craft Apps
     crate::http_server::endpoints::craft_apps::get_craft_apps_release_info_handler::get_craft_apps_release_info_handler,
+    crate::http_server::endpoints::craft_apps::get_craft_apps_flatpakrepo_handler::get_craft_apps_flatpakrepo_handler,
+    crate::http_server::endpoints::craft_apps::get_craft_apps_flatpak_repo_redirect_handler::get_craft_apps_flatpak_repo_redirect_handler,
+    crate::http_server::endpoints::craft_apps::get_craft_apps_download_handler::get_craft_apps_download_handler,
 
     // Tags
     crate::http_server::endpoints::tags::list_tags_handler::list_tags_handler,
@@ -1624,6 +1629,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
     CraftAppsImportantNotices,
     CraftAppsAnnouncement,
     CraftAppsAlert,
+    CraftAppFlatpakRepoConfig,
+    CraftAppFlatpakRepoInfo,
+    CraftAppDownloadErrorResponse,
+    CraftAppDownloadQuery,
     ListUntaggedMediaFilesQueryParams,
     ListUntaggedMediaFilesSuccessResponse,
     MediaFileTagsEntry,
diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_download_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_download_handler.rs
new file mode 100644
--- /dev/null
+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_download_handler.rs
@@ -0,0 +1,338 @@
+//! Directs clients and browsers to the appropriate GitHub Releases asset download URL
+//! via HTTP 302 redirect.
+
+use std::sync::Arc;
+
+use actix_web::http::header::{CacheControl, CacheDirective, LOCATION};
+use actix_web::{web, HttpResponse};
+use artcraft_api_defs::craft_apps::download::{CraftAppDownloadErrorResponse, CraftAppDownloadQuery};
+use artcraft_api_defs::craft_apps::release_info::{CraftAppArch, CraftAppOs, CraftAppPackageType, CraftAppReleaseAsset};
+use serde::Deserialize;
+use utoipa::ToSchema;
+
+use crate::state::server_state::ServerState;
+
+const REDIRECT_CACHE_SECS: u32 = 60;
+const NOT_FOUND_CACHE_SECS: u32 = 30;
+
+#[derive(Debug, Deserialize, ToSchema)]
+pub struct CraftAppDownloadPath {
+  /// App machine name or alias (e.g. `photocraft`, `cadcraft`, `artcraft-launcher`).
+  pub app: String,
+  /// Package kind, extension, or exact asset filename (e.g. `flatpak`, `appimage`, `deb`, `msi`, `dmg`).
+  pub kind: String,
+}
+
+/// Redirects to the direct GitHub Release asset download URL for the requested application
+/// and package type, avoiding the need to host binaries on web application pods or R2 storage.
+#[utoipa::path(
+  get,
+  tag = "Craft Apps",
+  path = "/v1/craft_apps/download/{app}/{kind}",
+  params(
+    ("app" = String, Path, description = "Craft application identifier (e.g. 'photocraft', 'cadcraft', 'artcraft-launcher')"),
+    ("kind" = String, Path, description = "Package kind, extension, or filename (e.g. 'flatpak', 'appimage', 'deb', 'rpm', 'dmg', 'msi', 'tar.gz', 'zip')"),
+    CraftAppDownloadQuery,
+  ),
+  responses(
+    (status = 302, description = "Redirect to GitHub Release asset download URL"),
+    (status = 404, description = "Unknown app, no releases published yet, or no matching asset", body = CraftAppDownloadErrorResponse),
+  ),
+)]
+pub async fn get_craft_apps_download_handler(server_state: web::Data<Arc<ServerState>>, path: web::Path<CraftAppDownloadPath>, query: web::Query<CraftAppDownloadQuery>) -> HttpResponse {
+  let path = path.into_inner();
+  let app_key = path.app.trim();
+  let kind = path.kind.trim();
+
+  let cached_app = match server_state.craft_apps_release_cache.get_app(app_key) {
+    Some(app) => app,
+    None => {
+      return HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("Unknown application '{app_key}'"), available_assets: None });
+    },
+  };
+
+  let release = match cached_app.latest_release {
+    Some(rel) => rel,
+    None => {
+      return HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("No releases currently available for application '{app_key}'"), available_assets: None });
+    },
+  };
+
+  let arch_filter = query.arch.as_deref();
+  let os_filter = query.os.as_deref();
+
+  match find_matching_asset(&release.assets, kind, arch_filter, os_filter) {
+    Some(asset) => HttpResponse::Found().insert_header((LOCATION, asset.download_url.clone())).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(REDIRECT_CACHE_SECS)])).finish(),
+    None => {
+      let available_assets = release.assets.iter().map(|a| a.name.clone()).collect();
+      HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("No asset found matching '{kind}' for application '{app_key}'"), available_assets: Some(available_assets) })
+    },
+  }
+}
+
+/// Finds the most appropriate asset in `assets` matching the specified `kind`,
+/// `arch_filter`, and `os_filter`.
+pub fn find_matching_asset<'a>(assets: &'a [CraftAppReleaseAsset], kind: &str, arch_filter: Option<&str>, os_filter: Option<&str>) -> Option<&'a CraftAppReleaseAsset> {
+  let kind_trimmed = kind.trim();
+  if kind_trimmed.is_empty() {
+    return None;
+  }
+
+  // 1. Direct exact filename match
+  if let Some(exact) = assets.iter().find(|a| a.name.eq_ignore_ascii_case(kind_trimmed)) {
+    return Some(exact);
+  }
+
+  // 2. Filter candidates matching kind and OS
+  let matching_kind: Vec<&'a CraftAppReleaseAsset> = assets.iter().filter(|a| matches_kind(a, kind_trimmed)).filter(|a| os_filter.is_none_or(|os| matches_os(a, os))).collect();
+
+  if matching_kind.is_empty() {
+    return None;
+  }
+
+  // 3. Filter or score by architecture
+  if let Some(arch_str) = arch_filter.map(str::trim).filter(|s| !s.is_empty()) {
+    if let Some(target_arch) = parse_arch(arch_str) {
+      // Prioritize exact arch match over Universal fallback
+      if let Some(exact_arch) = matching_kind.iter().find(|a| a.platform.as_ref().and_then(|p| p.arch).is_some_and(|arch| arch == target_arch)) {
+        return Some(*exact_arch);
+      }
+      if let Some(compatible) = matching_kind.iter().find(|a| matches_arch(a, target_arch)) {
+        return Some(*compatible);
+      }
+    } else {
+      let lower = arch_str.to_lowercase();
+      if let Some(matched) = matching_kind.iter().find(|a| a.name.to_lowercase().contains(&lower)) {
+        return Some(*matched);
+      }
+    }
+  }
+
+  // 4. Default architecture selection when arch is omitted:
+  // Prefer x86_64 / universal, then arm64, then x86.
+  matching_kind.into_iter().max_by_key(|a| arch_priority_score(a))
+}
+
+fn matches_kind(asset: &CraftAppReleaseAsset, kind: &str) -> bool {
+  let kind_lower = kind.to_lowercase();
+  let k = kind_lower.trim_start_matches('.');
+  let name_lower = asset.name.to_lowercase();
+
+  match k {
+    "flatpak" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Flatpak) || name_lower.ends_with(".flatpak"),
+    "appimage" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::AppImage) || (name_lower.ends_with(".appimage") && !name_lower.ends_with(".appimage.zsync")),
+    "zsync" | "appimage.zsync" | "appimagezsync" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::AppImageZsync) || name_lower.ends_with(".appimage.zsync"),
+    "deb" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Deb) || name_lower.ends_with(".deb"),
+    "rpm" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Rpm) || name_lower.ends_with(".rpm"),
+    "dmg" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Dmg) || name_lower.ends_with(".dmg"),
+    "msi" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Msi) || name_lower.ends_with(".msi"),
+    "tar.gz" | "targz" | "tar" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::TarGz) || name_lower.ends_with(".tar.gz"),
+    "portable" | "portable-zip" | "portable.zip" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::PortableZip) || name_lower.ends_with("-portable.zip"),
+    "web" | "wasm" | "webzip" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::WebZip) || (name_lower.contains("-web-") && name_lower.ends_with(".zip")),
+    "zip" => name_lower.ends_with(".zip"),
+    _ => name_lower.ends_with(&format!(".{k}")) || name_lower.ends_with(kind) || name_lower.contains(kind),
+  }
+}
+
+fn matches_os(asset: &CraftAppReleaseAsset, os_str: &str) -> bool {
+  let target_os = match os_str.to_lowercase().as_str() {
+    "linux" => CraftAppOs::Linux,
+    "windows" | "win" | "win64" | "win32" => CraftAppOs::Windows,
+    "macos" | "mac" | "darwin" | "osx" => CraftAppOs::Macos,
+    "freebsd" => CraftAppOs::Freebsd,
+    "web" | "wasm" => CraftAppOs::Web,
+    _ => return false,
+  };
+
+  if let Some(platform) = &asset.platform {
+    platform.os == target_os
+  } else {
+    matches_os_name_fallback(&asset.name.to_lowercase(), target_os)
+  }
+}
+
+fn matches_os_name_fallback(name_lower: &str, target_os: CraftAppOs) -> bool {
+  match target_os {
+    CraftAppOs::Windows if name_lower.ends_with(".msi") || name_lower.ends_with(".exe") => return true,
+    CraftAppOs::Macos if name_lower.ends_with(".dmg") || name_lower.ends_with(".pkg") => return true,
+    CraftAppOs::Linux if name_lower.ends_with(".deb") || name_lower.ends_with(".rpm") || name_lower.ends_with(".flatpak") => return true,
+    _ => {}
+  }
+
+  let tokens: Vec<&str> = name_lower.split(|c: char| !c.is_ascii_alphanumeric()).filter(|s| !s.is_empty()).collect();
+
+  match target_os {
+    CraftAppOs::Linux => tokens.contains(&"linux"),
+    CraftAppOs::Windows => tokens.iter().any(|&t| t == "windows" || t == "win" || t == "win64" || t == "win32"),
+    CraftAppOs::Macos => tokens.iter().any(|&t| t == "macos" || t == "darwin" || t == "osx" || t == "mac"),
+    CraftAppOs::Freebsd => tokens.contains(&"freebsd"),
+    CraftAppOs::Web => tokens.contains(&"web") || tokens.contains(&"wasm"),
+  }
+}
+
+fn parse_arch(arch_str: &str) -> Option<CraftAppArch> {
+  match arch_str.to_lowercase().as_str() {
+    "x64" | "x86_64" | "amd64" => Some(CraftAppArch::X64),
+    "x86" | "i386" | "i686" => Some(CraftAppArch::X86),
+    "arm64" | "aarch64" => Some(CraftAppArch::Arm64),
+    "universal" => Some(CraftAppArch::Universal),
+    _ => None,
+  }
+}
+
+fn matches_arch(asset: &CraftAppReleaseAsset, target_arch: CraftAppArch) -> bool {
+  if let Some(platform) = &asset.platform {
+    if let Some(arch) = platform.arch {
+      if arch == target_arch {
+        return true;
+      }
+      // macOS Universal packages run on both X64 and Arm64
+      if arch == CraftAppArch::Universal && (target_arch == CraftAppArch::X64 || target_arch == CraftAppArch::Arm64) {
+        return true;
+      }
+    }
+  }
+  let name_lower = asset.name.to_lowercase();
+  match target_arch {
+    CraftAppArch::X64 => name_lower.contains("x86_64") || name_lower.contains("x64") || name_lower.contains("amd64") || name_lower.contains("universal"),
+    CraftAppArch::Arm64 => name_lower.contains("aarch64") || name_lower.contains("arm64") || name_lower.contains("universal"),
+    CraftAppArch::X86 => name_lower.contains("x86") || name_lower.contains("i386") || name_lower.contains("i686"),
+    CraftAppArch::Universal => name_lower.contains("universal"),
+  }
+}
+
+fn arch_priority_score(asset: &CraftAppReleaseAsset) -> u8 {
+  if let Some(platform) = &asset.platform {
+    match platform.arch {
+      Some(CraftAppArch::X64) | Some(CraftAppArch::Universal) => 10,
+      Some(CraftAppArch::Arm64) => 5,
+      Some(CraftAppArch::X86) => 2,
+      None => 1,
+    }
+  } else {
+    let name = asset.name.to_lowercase();
+    if name.contains("x86_64") || name.contains("x64") || name.contains("universal") {
+      8
+    } else if name.contains("aarch64") || name.contains("arm64") {
+      4
+    } else {
+      1
+    }
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+  use artcraft_api_defs::craft_apps::release_info::CraftAppAssetPlatform;
+
+  fn test_asset(name: &str, os: CraftAppOs, arch: Option<CraftAppArch>, package_type: CraftAppPackageType) -> CraftAppReleaseAsset {
+    CraftAppReleaseAsset { name: name.to_string(), size_bytes: 12345, download_url: format!("https://github.com/storytold/photocraft/releases/download/v0.5.0/{name}"), sha256: None, platform: Some(CraftAppAssetPlatform { os, arch, package_type }) }
+  }
+
+  #[test]
+  fn exact_filename_match_takes_priority() {
+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak)];
+    let matched = find_matching_asset(&assets, "photocraft-0.5.0-linux-aarch64.flatpak", None, None);
+    assert_eq!(matched.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");
+  }
+
+  #[test]
+  fn default_flatpak_selects_x86_64() {
+    let assets = vec![test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak)];
+    let matched = find_matching_asset(&assets, "flatpak", None, None);
+    assert_eq!(matched.unwrap().name, "photocraft-0.5.0-linux-x86_64.flatpak");
+  }
+
+  #[test]
+  fn flatpak_respects_arch_filter() {
+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak)];
+    let matched_arm = find_matching_asset(&assets, "flatpak", Some("aarch64"), None);
+    assert_eq!(matched_arm.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");
+
+    let matched_arm64 = find_matching_asset(&assets, "flatpak", Some("arm64"), None);
+    assert_eq!(matched_arm64.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");
+
+    let matched_x64 = find_matching_asset(&assets, "flatpak", Some("x86_64"), None);
+    assert_eq!(matched_x64.unwrap().name, "photocraft-0.5.0-linux-x86_64.flatpak");
+  }
+
+  #[test]
+  fn distinguishes_appimage_from_zsync() {
+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.AppImage.zsync", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::AppImageZsync), test_asset("photocraft-0.5.0-linux-x86_64.AppImage", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::AppImage)];
+    let matched_ai = find_matching_asset(&assets, "appimage", None, None);
+    assert_eq!(matched_ai.unwrap().name, "photocraft-0.5.0-linux-x86_64.AppImage");
+
+    let matched_zsync = find_matching_asset(&assets, "zsync", None, None);
+    assert_eq!(matched_zsync.unwrap().name, "photocraft-0.5.0-linux-x86_64.AppImage.zsync");
+  }
+
+  #[test]
+  fn universal_macos_dmg_serves_arm64_and_x64() {
+    let assets = vec![test_asset("photocraft-0.5.0-macos-universal.dmg", CraftAppOs::Macos, Some(CraftAppArch::Universal), CraftAppPackageType::Dmg), test_asset("photocraft-0.5.0-windows-x64.msi", CraftAppOs::Windows, Some(CraftAppArch::X64), CraftAppPackageType::Msi)];
+    let matched_mac = find_matching_asset(&assets, "dmg", None, None);
+    assert_eq!(matched_mac.unwrap().name, "photocraft-0.5.0-macos-universal.dmg");
+
+    let matched_arm = find_matching_asset(&assets, "dmg", Some("arm64"), None);
+    assert_eq!(matched_arm.unwrap().name, "photocraft-0.5.0-macos-universal.dmg");
+
+    let matched_msi = find_matching_asset(&assets, "msi", None, None);
+    assert_eq!(matched_msi.unwrap().name, "photocraft-0.5.0-windows-x64.msi");
+  }
+
+  #[test]
+  fn package_types_and_tarball_lookup() {
+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.deb", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Deb), test_asset("photocraft-0.5.0-linux-x86_64.rpm", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Rpm), test_asset("photocraft-0.5.0-linux-x86_64.tar.gz", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::TarGz), test_asset("photocraft-0.5.0-windows-x64-portable.zip", CraftAppOs::Windows, Some(CraftAppArch::X64), CraftAppPackageType::PortableZip)];
+    assert_eq!(find_matching_asset(&assets, "deb", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.deb");
+    assert_eq!(find_matching_asset(&assets, "rpm", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.rpm");
+    assert_eq!(find_matching_asset(&assets, "tar.gz", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.tar.gz");
+    assert_eq!(find_matching_asset(&assets, "portable", None, None).unwrap().name, "photocraft-0.5.0-windows-x64-portable.zip");
+  }
+
+  #[test]
+  fn returns_none_on_unknown_kind_or_empty_assets() {
+    let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak)];
+    assert!(find_matching_asset(&assets, "exe", None, None).is_none());
+    assert!(find_matching_asset(&[], "flatpak", None, None).is_none());
+    assert!(find_matching_asset(&assets, "", None, None).is_none());
+  }
+
+  #[test]
+  fn os_fallback_distinguishes_windows_and_darwin() {
+    let darwin_asset = CraftAppReleaseAsset {
+      name: "photocraft-0.5.0-darwin-x86_64.tar.gz".to_string(),
+      size_bytes: 12345,
+      download_url: "https://example.com/darwin".to_string(),
+      sha256: None,
+      platform: None,
+    };
+    let win_asset = CraftAppReleaseAsset {
+      name: "photocraft-0.5.0-win-x64.zip".to_string(),
+      size_bytes: 12345,
+      download_url: "https://example.com/win".to_string(),
+      sha256: None,
+      platform: None,
+    };
+    let macro_asset = CraftAppReleaseAsset {
+      name: "photocraft-macro-tools-0.5.0-linux.tar.gz".to_string(),
+      size_bytes: 12345,
+      download_url: "https://example.com/macro".to_string(),
+      sha256: None,
+      platform: None,
+    };
+
+    assert!(matches_os(&darwin_asset, "macos"));
+    assert!(matches_os(&darwin_asset, "darwin"));
+    assert!(!matches_os(&darwin_asset, "windows"));
+    assert!(!matches_os(&darwin_asset, "win"));
+
+    assert!(matches_os(&win_asset, "windows"));
+    assert!(matches_os(&win_asset, "win"));
+    assert!(!matches_os(&win_asset, "macos"));
+    assert!(!matches_os(&win_asset, "darwin"));
+
+    assert!(matches_os(&macro_asset, "linux"));
+    assert!(!matches_os(&macro_asset, "macos"));
+    assert!(!matches_os(&macro_asset, "mac"));
+  }
+}
diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpak_repo_redirect_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpak_repo_redirect_handler.rs
new file mode 100644
--- /dev/null
+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpak_repo_redirect_handler.rs
@@ -0,0 +1,64 @@
+//! Redirects Flatpak OSTree repository requests to the backing cloud object storage (S3/R2).
+
+use actix_web::http::header::{CacheControl, CacheDirective, LOCATION};
+use actix_web::{web, HttpResponse};
+
+const DEFAULT_OBJECT_CACHE_SECS: u32 = 86400;
+const METADATA_CACHE_SECS: u32 = 60;
+
+/// Redirects Flatpak repository file requests (summary, refs, objects, deltas)
+/// to the backing S3 or Cloudflare R2 object storage CDN.
+#[utoipa::path(
+  get,
+  tag = "Craft Apps",
+  path = "/v1/craft_apps/flatpak/repo/{tail:.*}",
+  params(
+    ("tail" = String, Path, description = "Relative path within the OSTree repository (e.g. 'summary', 'config', 'objects/..')"),
+  ),
+  responses(
+    (status = 307, description = "Redirect to the backing OSTree storage CDN"),
+  ),
+)]
+pub async fn get_craft_apps_flatpak_repo_redirect_handler(tail: web::Path<String>) -> HttpResponse {
+  let relative_path = tail.into_inner();
+  if relative_path.ends_with(".flatpakrepo") {
+    return HttpResponse::NotFound().finish();
+  }
+
+  let base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");
+  let trimmed_base = base_url.trim_end_matches('/');
+
+  let destination_url = if relative_path.is_empty() { format!("{trimmed_base}/") } else { format!("{trimmed_base}/{}", relative_path.trim_start_matches('/')) };
+
+  let max_age = if relative_path.starts_with("objects/") || relative_path.starts_with("deltas/") { DEFAULT_OBJECT_CACHE_SECS } else { METADATA_CACHE_SECS };
+
+  HttpResponse::TemporaryRedirect().insert_header((LOCATION, destination_url)).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(max_age)])).finish()
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+  use actix_web::http::StatusCode;
+
+  #[actix_web::test]
+  async fn redirects_with_307_and_location() {
+    let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("summary".to_string())).await;
+    assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
+    let loc = resp.headers().get(LOCATION).expect("Location header");
+    assert_eq!(loc, "https://repo.getartcraft.com/summary");
+  }
+
+  #[actix_web::test]
+  async fn redirects_objects_path_with_307() {
+    let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("objects/01/23.filez".to_string())).await;
+    assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
+    let loc = resp.headers().get(LOCATION).expect("Location header");
+    assert_eq!(loc, "https://repo.getartcraft.com/objects/01/23.filez");
+  }
+
+  #[actix_web::test]
+  async fn rejects_flatpakrepo_under_repo_path() {
+    let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("artcraft.flatpakrepo".to_string())).await;
+    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+  }
+}
diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpakrepo_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpakrepo_handler.rs
new file mode 100644
--- /dev/null
+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpakrepo_handler.rs
@@ -0,0 +1,144 @@
+//! Serves .flatpakrepo configuration files for ArtCraft Suite and individual Craft Apps.
+
+use std::sync::Arc;
+
+use actix_web::http::header::{CacheControl, CacheDirective, ContentDisposition, DispositionParam, DispositionType};
+use actix_web::{web, HttpResponse};
+use artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoConfig;
+
+use crate::state::server_state::ServerState;
+
+const FLATPAK_MIME_TYPE: &str = "application/vnd.flatpak.repo; charset=utf-8";
+const CACHE_MAX_AGE_SECS: u32 = 3600;
+
+/// Serves a `.flatpakrepo` configuration file for the entire ArtCraft Suite
+/// (`artcraft.flatpakrepo`) or an individual Craft app (`<app>.flatpakrepo`).
+#[utoipa::path(
+  get,
+  tag = "Craft Apps",
+  path = "/v1/craft_apps/flatpak/{target}",
+  params(
+    ("target" = String, Path, description = "Target .flatpakrepo filename, e.g. 'artcraft.flatpakrepo' or 'photocraft.flatpakrepo'"),
+  ),
+  responses(
+    (status = 200, description = "Flatpak repository definition file (.flatpakrepo)"),
+    (status = 404, description = "Unknown app or invalid filename extension"),
+  ),
+)]
+pub async fn get_craft_apps_flatpakrepo_handler(server_state: web::Data<Arc<ServerState>>, target: web::Path<String>) -> HttpResponse {
+  handle_craft_apps_flatpakrepo(&server_state.craft_apps_release_cache, target.as_str())
+}
+
+/// Core logic for handling `.flatpakrepo` file requests.
+pub fn handle_craft_apps_flatpakrepo(cache: &crate::state::craft_apps_release_cache::CraftAppsReleaseCache, target_filename: &str) -> HttpResponse {
+  if !target_filename.ends_with(".flatpakrepo") {
+    return HttpResponse::NotFound().finish();
+  }
+
+  let stem = target_filename.strip_suffix(".flatpakrepo").unwrap_or("");
+  if !is_valid_stem(stem) {
+    return HttpResponse::NotFound().finish();
+  }
+
+  let repo_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");
+  let website_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_WEBSITE_BASE_URL", "https://getartcraft.com");
+  let default_gpg_key = format!("{}/keys/flatpak.gpg", website_base_url.trim_end_matches('/'));
+  let gpg_key_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_GPG_KEY_URL", &default_gpg_key);
+
+  let content = if stem == "artcraft" || stem == "suite" {
+    CraftAppFlatpakRepoConfig::suite_config(&repo_base_url, &website_base_url, Some(gpg_key_url)).to_flatpakrepo_content()
+  } else {
+    let (app_key, app) = match cache.get_app_with_key(stem) {
+      Some(entry) => entry,
+      None => return HttpResponse::NotFound().finish(),
+    };
+
+    let app_env_key = app_key.to_uppercase().replace('-', "_");
+    let app_gpg_key = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_GPG_KEY_URL_{app_env_key}")).unwrap_or(gpg_key_url);
+    let app_repo_url = format!("{}/{}", repo_base_url.trim_end_matches('/'), app_key);
+    let app_homepage = format!("{}/apps/{}", website_base_url.trim_end_matches('/'), app_key);
+    CraftAppFlatpakRepoConfig::app_config(&app_key, &app.name, &app_repo_url, &app_homepage, Some(app_gpg_key)).to_flatpakrepo_content()
+  };
+
+  let disposition = ContentDisposition { disposition: DispositionType::Inline, parameters: vec![DispositionParam::Filename(target_filename.to_string())] };
+
+  HttpResponse::Ok().content_type(FLATPAK_MIME_TYPE).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(CACHE_MAX_AGE_SECS)])).insert_header(disposition).body(content)
+}
+
+fn is_valid_stem(stem: &str) -> bool {
+  !stem.is_empty() && stem.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+  use actix_web::http::StatusCode;
+  use crate::state::craft_apps_release_cache::CraftAppsReleaseCache;
+
+  #[test]
+  fn validates_stems() {
+    assert!(is_valid_stem("photocraft"));
+    assert!(is_valid_stem("artcraft-launcher"));
+    assert!(is_valid_stem("cadcraft-1"));
+    assert!(!is_valid_stem(""));
+    assert!(!is_valid_stem("PhotoCraft"));
+    assert!(!is_valid_stem("photo\ncraft"));
+    assert!(!is_valid_stem("photo craft"));
+    assert!(!is_valid_stem("photo/craft"));
+    assert!(!is_valid_stem("photo%0Acraft"));
+  }
+
+  #[test]
+  fn generates_suite_and_app_repo_content() {
+    let suite = CraftAppFlatpakRepoConfig::suite_config("https://repo.getartcraft.com", "https://getartcraft.com", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));
+    let suite_ini = suite.to_flatpakrepo_content();
+    assert!(suite_ini.contains("[Flatpak Repo]"));
+    assert!(suite_ini.contains("Title=ArtCraft"));
+    assert!(suite_ini.contains("Url=https://repo.getartcraft.com/"));
+    assert!(suite_ini.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));
+
+    let app = CraftAppFlatpakRepoConfig::app_config("cadcraft", "CADCraft", "https://repo.getartcraft.com/cadcraft", "https://getartcraft.com/apps/cadcraft", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));
+    let app_ini = app.to_flatpakrepo_content();
+    assert!(app_ini.contains("Title=CADCraft"));
+    assert!(app_ini.contains("Url=https://repo.getartcraft.com/cadcraft/"));
+    assert!(app_ini.contains("Homepage=https://getartcraft.com/apps/cadcraft"));
+    assert!(app_ini.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));
+  }
+
+  #[actix_web::test]
+  async fn flatpakrepo_suite_and_app_responses() {
+    let cache = CraftAppsReleaseCache::new();
+    cache.register_apps(&[
+      ("photocraft".into(), "PhotoCraft".into(), "storytold/photocraft".into()),
+      ("artcraft-launcher".into(), "ArtCraft Launcher".into(), "storytold/craft-launcher".into()),
+    ]);
+
+    // Suite returns 200 with GPGKey
+    let resp = handle_craft_apps_flatpakrepo(&cache, "artcraft.flatpakrepo");
+    assert_eq!(resp.status(), StatusCode::OK);
+    let body_bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
+    let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
+    assert!(body_str.contains("Title=ArtCraft"));
+    assert!(body_str.contains("GPGKey="));
+
+    // Known app returns 200 with GPGKey and cached app name
+    let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft.flatpakrepo");
+    assert_eq!(resp.status(), StatusCode::OK);
+    let body_bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
+    let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
+    assert!(body_str.contains("Title=PhotoCraft"));
+    assert!(body_str.contains("GPGKey="));
+
+    // Unknown app returns 404
+    let resp = handle_craft_apps_flatpakrepo(&cache, "anything.flatpakrepo");
+    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+
+    // Missing .flatpakrepo extension returns 404
+    let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft");
+    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+
+    // Newline injection attempt returns 404
+    let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft\nGPGKey=evil.flatpakrepo");
+    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+  }
+}
diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs
--- a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs
+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs
@@ -25,7 +25,24 @@ const CACHE_MAX_AGE_SECS: u32 = 60;
 pub async fn get_craft_apps_release_info_handler(
   server_state: web::Data<Arc<ServerState>>,
 ) -> HttpResponse {
-  let release_info = server_state.craft_apps_release_cache.snapshot();
+  let flatpak_config = server_state.craft_apps_release_cache.load_flatpak_config_from_env();
+
+  let suite_flatpak_repo = if flatpak_config.enabled {
+    Some(artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoInfo {
+      app_id: "ai.storyteller.artcraft".to_string(),
+      repo_url: format!("{}/", flatpak_config.repo_base_url.trim_end_matches('/')),
+      flatpakrepo_url: format!("{}/v1/craft_apps/flatpak/artcraft.flatpakrepo", flatpak_config.api_base_url.trim_end_matches('/')),
+      branch: "stable".to_string(),
+      version: None,
+      min_version: None,
+      is_synced: None,
+      gpg_key_url: flatpak_config.gpg_key_url.clone(),
+    })
+  } else {
+    None
+  };
+
+  let release_info = server_state.craft_apps_release_cache.snapshot_with_flatpak_config(&flatpak_config);
   let cache_control = cache_control_for(&release_info);
   let response = GetCraftAppsReleaseInfoResponse {
     success: true,
@@ -34,6 +51,7 @@ pub async fn get_craft_apps_release_info_handler(
     app_renames: Vec::new(),
     repo_renames: Vec::new(),
     important_notices: CraftAppsImportantNotices::default(),
+    suite_flatpak_repo,
   };
   HttpResponse::Ok()
       .insert_header(cache_control)
diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs
--- a/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs
+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs
@@ -1 +1,4 @@
+pub mod get_craft_apps_download_handler;
+pub mod get_craft_apps_flatpak_repo_redirect_handler;
+pub mod get_craft_apps_flatpakrepo_handler;
 pub mod get_craft_apps_release_info_handler;
diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs
--- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs
+++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs
@@ -3,6 +3,9 @@ use actix_service::ServiceFactory;
 use actix_web::dev::{ServiceRequest, ServiceResponse};
 use actix_web::{web, App, Error, HttpResponse};
 
+use crate::http_server::endpoints::craft_apps::get_craft_apps_download_handler::get_craft_apps_download_handler;
+use crate::http_server::endpoints::craft_apps::get_craft_apps_flatpak_repo_redirect_handler::get_craft_apps_flatpak_repo_redirect_handler;
+use crate::http_server::endpoints::craft_apps::get_craft_apps_flatpakrepo_handler::get_craft_apps_flatpakrepo_handler;
 use crate::http_server::endpoints::craft_apps::get_craft_apps_release_info_handler::get_craft_apps_release_info_handler;
 
 pub fn add_craft_apps_routes<T, B>(app: App<T>) -> App<T>
@@ -23,6 +26,29 @@ where
         web::resource("/release_info")
           .route(web::get().to(get_craft_apps_release_info_handler))
           .route(web::head().to(|| HttpResponse::Ok())),
+      )
+      // Flatpak repository redirection to S3/R2 storage backend
+      .service(
+        web::resource("/flatpak/repo/{tail:.*}")
+          .route(web::get().to(get_craft_apps_flatpak_repo_redirect_handler))
+          .route(web::head().to(get_craft_apps_flatpak_repo_redirect_handler)),
+      )
+      // Flatpak repository definitions (.flatpakrepo) for suite or apps
+      .service(
+        web::resource("/flatpak/{target}")
+          .route(web::get().to(get_craft_apps_flatpakrepo_handler))
+          .route(web::head().to(get_craft_apps_flatpakrepo_handler)),
+      )
+      // Asset download redirection to GitHub Releases
+      .service(
+        web::resource("/download/{app}/{kind}")
+          .route(web::get().to(get_craft_apps_download_handler))
+          .route(web::head().to(get_craft_apps_download_handler)),
+      )
+      .service(
+        web::resource("/{app}/download/{kind}")
+          .route(web::get().to(get_craft_apps_download_handler))
+          .route(web::head().to(get_craft_apps_download_handler)),
       ),
   )
 }
diff --git a/crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs b/crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs
--- a/crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs
+++ b/crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs
@@ -159,25 +159,163 @@ impl CraftAppsReleaseCache {
     }
   }
 
+  /// Returns the canonical app key and cached app info for the specified key or alias, if known.
+  pub fn get_app_with_key(&self, key: &str) -> Option<(String, CachedCraftApp)> {
+    let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);
+    let lower = key.to_lowercase();
+    if let Some((k, app)) = map.get_key_value(key).or_else(|| map.get_key_value(&lower)) {
+      return Some((k.clone(), app.clone()));
+    }
+    for (k, app) in map.iter() {
+      if k.eq_ignore_ascii_case(key)
+        || app.name.eq_ignore_ascii_case(key)
+        || app.repo.eq_ignore_ascii_case(key)
+        || app.repo.split('/').last().is_some_and(|r| r.eq_ignore_ascii_case(key))
+        || (key.eq_ignore_ascii_case("launcher") && k == "artcraft-launcher")
+        || (key.eq_ignore_ascii_case("craft-launcher") && k == "artcraft-launcher")
+      {
+        return Some((k.clone(), app.clone()));
+      }
+    }
+    None
+  }
+
+  /// Returns the cached app info for the specified key or alias, if known.
+  pub fn get_app(&self, key: &str) -> Option<CachedCraftApp> {
+    self.get_app_with_key(key).map(|(_, app)| app)
+  }
+
+  /// Returns the latest release for the specified app key (e.g. `photocraft`, `artcraft-launcher`),
+  /// if fetched.
+  pub fn get_app_release(&self, key: &str) -> Option<CraftAppRelease> {
+    self.get_app(key).and_then(|app| app.latest_release)
+  }
+
+  /// Helper to load Flatpak config from environment once using registered app keys.
+  pub fn load_flatpak_config_from_env(&self) -> CraftAppsFlatpakConfig {
+    let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);
+    CraftAppsFlatpakConfig::from_env(map.keys())
+  }
+
   /// Everything, as the endpoint serves it: `apps` keyed by app key.
+  /// Uses default (disabled) flatpak config without reading environment variables.
   pub fn snapshot(&self) -> CraftAppsReleaseInfo {
+    self.snapshot_with_flatpak_config(&CraftAppsFlatpakConfig::default())
+  }
+
+  /// Takes explicit flatpak config / overrides rather than reading environment variables
+  /// inside `snapshot()`.
+  pub fn snapshot_with_flatpak_config(&self, config: &CraftAppsFlatpakConfig) -> CraftAppsReleaseInfo {
     let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);
+
     let apps: BTreeMap<String, CraftAppReleaseInfo> = map
-        .iter()
-        .map(|(key, app)| {
-          (key.clone(), CraftAppReleaseInfo {
-            name: app.name.clone(),
-            repo: app.repo.clone(),
-            latest_release: app.latest_release.clone(),
-            app_last_fetched: app.app_last_fetched,
+      .iter()
+      .map(|(key, app)| {
+        let flatpak_repo = if config.enabled {
+          let app_override = config.app_overrides.get(key);
+          let app_version_override = app_override.and_then(|o| o.version.clone());
+          let min_version = app_override.and_then(|o| o.min_version.clone());
+          let latest_v = app.latest_release.as_ref().map(|r| r.version.clone());
+
+          let (version, is_synced) = match (app_version_override, latest_v) {
+            (Some(override_v), Some(latest)) => {
+              let synced = artcraft_api_defs::craft_apps::flatpak::compare_versions(&override_v, &latest) != Some(std::cmp::Ordering::Less);
+              (Some(override_v), Some(synced))
+            }
+            (Some(override_v), None) => (Some(override_v), None),
+            (None, _) => (None, None),
+          };
+
+          Some(artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoInfo {
+            app_id: format!("ai.storyteller.{key}"),
+            repo_url: format!("{}/{}", config.repo_base_url.trim_end_matches('/'), key),
+            flatpakrepo_url: format!("{}/v1/craft_apps/flatpak/{key}.flatpakrepo", config.api_base_url.trim_end_matches('/')),
+            branch: "stable".to_string(),
+            version,
+            min_version,
+            is_synced,
+            gpg_key_url: config.gpg_key_url.clone(),
           })
+        } else {
+          None
+        };
+
+        (key.clone(), CraftAppReleaseInfo {
+          name: app.name.clone(),
+          repo: app.repo.clone(),
+          latest_release: app.latest_release.clone(),
+          app_last_fetched: app.app_last_fetched,
+          flatpak_repo,
         })
-        .collect();
+      })
+      .collect();
     let last_fetched = least_recently_fetched(&apps);
     CraftAppsReleaseInfo { apps, last_fetched }
   }
 }
 
+/// Configuration for Flatpak repository advertising in release info responses.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CraftAppsFlatpakConfig {
+  pub enabled: bool,
+  pub repo_base_url: String,
+  pub api_base_url: String,
+  pub gpg_key_url: Option<String>,
+  pub app_overrides: BTreeMap<String, CraftAppFlatpakOverride>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct CraftAppFlatpakOverride {
+  pub version: Option<String>,
+  pub min_version: Option<String>,
+}
+
+impl Default for CraftAppsFlatpakConfig {
+  fn default() -> Self {
+    Self {
+      enabled: false,
+      repo_base_url: "https://repo.getartcraft.com".to_string(),
+      api_base_url: "https://api.getartcraft.com".to_string(),
+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),
+      app_overrides: BTreeMap::new(),
+    }
+  }
+}
+
+impl CraftAppsFlatpakConfig {
+  /// Reads flatpak configuration and per-app overrides from environment variables once,
+  /// outside of snapshot().
+  pub fn from_env(known_app_keys: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
+    let enabled = easyenv::get_env_bool_or_default("CRAFT_APPS_FLATPAK_REPO_ENABLED", false);
+    let repo_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");
+    let api_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_API_BASE_URL", "https://api.getartcraft.com");
+    let website_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_WEBSITE_BASE_URL", "https://getartcraft.com");
+    let default_gpg_key = format!("{}/keys/flatpak.gpg", website_base_url.trim_end_matches('/'));
+    let gpg_key_url = Some(easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_GPG_KEY_URL", &default_gpg_key));
+
+    let mut app_overrides = BTreeMap::new();
+    if enabled {
+      for app_key in known_app_keys {
+        let key_str = app_key.as_ref();
+        let app_env_key = key_str.to_uppercase().replace('-', "_");
+        let version = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_VERSION_{app_env_key}"));
+        let min_version = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_MIN_VERSION_{app_env_key}"));
+        if version.is_some() || min_version.is_some() {
+          app_overrides.insert(key_str.to_string(), CraftAppFlatpakOverride { version, min_version });
+        }
+      }
+    }
+
+    Self {
+      enabled,
+      repo_base_url,
+      api_base_url,
+      gpg_key_url,
+      app_overrides,
+    }
+  }
+}
+
 impl CachedCraftApp {
   /// Whether the last request for this app failed (no success since).
   pub fn is_failing(&self) -> bool {
@@ -276,4 +414,130 @@ mod tests {
     cache.merge_failure("photocraft", t(5), 4);
     assert_eq!(cache.app("photocraft").unwrap().consecutive_failures, 4);
   }
+
+  #[test]
+  fn snapshot_populates_flatpak_repo_info() {
+    let cache = CraftAppsReleaseCache::new();
+    let apps = [("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string())];
+    cache.register_apps(&apps);
+
+    // Default snapshot has flatpak disabled:
+    let snapshot = cache.snapshot();
+    assert!(snapshot.apps["photocraft"].flatpak_repo.is_none());
+
+    // With flatpak enabled:
+    let config = CraftAppsFlatpakConfig {
+      enabled: true,
+      repo_base_url: "https://repo.getartcraft.com".to_string(),
+      api_base_url: "https://api.getartcraft.com".to_string(),
+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),
+      app_overrides: BTreeMap::new(),
+    };
+    let snapshot = cache.snapshot_with_flatpak_config(&config);
+    let flatpak = snapshot.apps["photocraft"].flatpak_repo.as_ref().unwrap();
+    assert_eq!(flatpak.app_id, "ai.storyteller.photocraft");
+    assert!(flatpak.repo_url.contains("photocraft"));
+    assert!(flatpak.flatpakrepo_url.ends_with("photocraft.flatpakrepo"));
+    assert_eq!(flatpak.branch, "stable");
+    assert_eq!(flatpak.gpg_key_url.as_deref(), Some("https://getartcraft.com/keys/flatpak.gpg"));
+  }
+
+  #[test]
+  fn snapshot_detects_lagging_flatpak_repo() {
+    let cache = CraftAppsReleaseCache::new();
+    let apps = [("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string())];
+    cache.register_apps(&apps);
+    let release = artcraft_api_defs::craft_apps::release_info::CraftAppRelease {
+      tag: "v0.5.0".to_string(),
+      version: "0.5.0".to_string(),
+      name: "PhotoCraft 0.5.0".to_string(),
+      published_at: None,
+      html_url: "https://github.com/storytold/photocraft/releases/tag/v0.5.0".to_string(),
+      prerelease: false,
+      notes_markdown: String::new(),
+      assets: Vec::new(),
+    };
+    cache.store("photocraft", Some(release), None, Utc::now());
+
+    // Without override, version and is_synced are None (unknown repo state):
+    let config_no_override = CraftAppsFlatpakConfig {
+      enabled: true,
+      repo_base_url: "https://repo.getartcraft.com".to_string(),
+      api_base_url: "https://api.getartcraft.com".to_string(),
+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),
+      app_overrides: BTreeMap::new(),
+    };
+    let snapshot = cache.snapshot_with_flatpak_config(&config_no_override);
+    let flatpak = snapshot.apps["photocraft"].flatpak_repo.as_ref().unwrap();
+    assert_eq!(flatpak.version, None);
+    assert_eq!(flatpak.is_synced, None);
+
+    // With explicit override simulating repo lag (0.4.0 < 0.5.0):
+    let mut overrides = BTreeMap::new();
+    overrides.insert("photocraft".to_string(), CraftAppFlatpakOverride {
+      version: Some("0.4.0".to_string()),
+      min_version: None,
+    });
+    let config_lag = CraftAppsFlatpakConfig {
+      enabled: true,
+      repo_base_url: "https://repo.getartcraft.com".to_string(),
+      api_base_url: "https://api.getartcraft.com".to_string(),
+      gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),
+      app_overrides: overrides,
+    };
+    let snapshot_lag = cache.snapshot_with_flatpak_config(&config_lag);
+    let flatpak_lag = snapshot_lag.apps["photocraft"].flatpak_repo.as_ref().unwrap();
+    assert_eq!(flatpak_lag.version.as_deref(), Some("0.4.0"));
+    assert_eq!(flatpak_lag.is_synced, Some(false));
+    assert!(flatpak_lag.would_downgrade("0.5.0"));
+  }
+
+  #[test]
+  fn get_app_and_release_lookup() {
+    let cache = CraftAppsReleaseCache::new();
+    let apps = [
+      ("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string()),
+      ("artcraft-launcher".to_string(), "ArtCraft Launcher".to_string(), "storytold/craft-launcher".to_string()),
+    ];
+    cache.register_apps(&apps);
+
+    // Initial state: app known, but no release
+    let app_meta = cache.get_app("photocraft");
+    assert!(app_meta.is_some());
+    assert_eq!(app_meta.as_ref().unwrap().name, "PhotoCraft");
+    assert!(cache.get_app_release("photocraft").is_none());
+
+    let (canonical_key, app_entry) = cache.get_app_with_key("photocraft").unwrap();
+    assert_eq!(canonical_key, "photocraft");
+    assert_eq!(app_entry.name, "PhotoCraft");
+
+    // Alias lookups
+    assert!(cache.get_app("PhotoCraft").is_some());
+    assert!(cache.get_app("launcher").is_some());
+    assert!(cache.get_app("craft-launcher").is_some());
+    let (alias_key, _) = cache.get_app_with_key("craft-launcher").unwrap();
+    assert_eq!(alias_key, "artcraft-launcher");
+    assert!(cache.get_app("unknown-app").is_none());
+
+    // Once release stored:
+    let release = artcraft_api_defs::craft_apps::release_info::CraftAppRelease {
+      tag: "v0.5.0".to_string(),
+      version: "0.5.0".to_string(),
+      name: "PhotoCraft 0.5.0".to_string(),
+      published_at: None,
+      html_url: "https://github.com/storytold/photocraft/releases/tag/v0.5.0".to_string(),
+      prerelease: false,
+      notes_markdown: String::new(),
+      assets: Vec::new(),
+    };
+    cache.store("photocraft", Some(release), None, Utc::now());
+
+    let fetched = cache.get_app_release("photocraft");
+    assert!(fetched.is_some());
+    assert_eq!(fetched.unwrap().version, "0.5.0");
+
+    let fetched_alias = cache.get_app_release("PhotoCraft");
+    assert!(fetched_alias.is_some());
+    assert_eq!(fetched_alias.unwrap().version, "0.5.0");
+  }
 }
diff --git a/crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs b/crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs
--- a/crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs
+++ b/crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs
@@ -326,6 +326,7 @@ mod tests {
       app_renames: Vec::new(),
       repo_renames: Vec::new(),
       important_notices: CraftAppsImportantNotices::default(),
+      suite_flatpak_repo: None,
     };
     let json = serde_json::to_string_pretty(&response).unwrap();
     if let Ok(path) = std::env::var("CRAFT_APPS_SAMPLE_OUT") {
