Skip to main content

systemprompt_api/routes/gateway/bridge_release/
mod.rs

1//! `GET /v1/bridge/latest` and `GET /v1/bridge/download/{platform}` — the feed
2//! the desktop bridge's self-updater reads.
3//!
4//! Release assets live in a private repository that the bridge has no
5//! credential for, so the gateway resolves the newest `bridge-v*` release and
6//! proxies the bytes. Resolution happening here is also what lets an operator
7//! pin or stage a rollout without shipping a new client.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::sync::Arc;
13
14use axum::Json;
15use axum::body::Body;
16use axum::extract::{Path, Query};
17use axum::http::{HeaderMap, StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use serde::{Deserialize, Serialize};
20use systemprompt_identifiers::JwtToken;
21use systemprompt_loader::ServicesBootstrap;
22use systemprompt_models::services::BridgeReleasesSpec;
23
24mod github;
25
26pub use self::github::parse_sha256sums;
27
28use self::github::{asset_digest, github, resolve_release};
29
30use super::messages::extract_credential;
31use crate::services::middleware::JwtContextExtractor;
32
33
34#[derive(Debug, Deserialize)]
35pub struct LatestQuery {
36    pub platform: String,
37}
38
39/// Mirrors `ReleaseManifest` in the bridge's gateway client.
40///
41/// Keep the two in lockstep: this is a wire contract with an already-shipped
42/// binary, so a renamed field silently breaks every bridge in the field.
43#[derive(Debug, Serialize)]
44pub struct ReleaseManifest {
45    pub version: String,
46    pub sha256: String,
47    pub size: u64,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub notes_url: Option<String>,
50}
51
52pub async fn latest(
53    jwt_extractor: Arc<JwtContextExtractor>,
54    headers: HeaderMap,
55    Query(query): Query<LatestQuery>,
56) -> Result<Json<ReleaseManifest>, (StatusCode, String)> {
57    authenticate(&jwt_extractor, &headers).await?;
58    let spec = releases_spec()?;
59
60    let asset_name = spec.assets.get(&query.platform).ok_or_else(|| {
61        (
62            StatusCode::NOT_FOUND,
63            format!("no published build for platform {}", query.platform),
64        )
65    })?;
66
67    let release = resolve_release(&spec).await?;
68    let asset = release
69        .assets
70        .iter()
71        .find(|a| a.name == *asset_name)
72        .ok_or_else(|| {
73            (
74                StatusCode::NOT_FOUND,
75                format!("release {} has no asset {asset_name}", release.tag_name),
76            )
77        })?;
78
79    let version = release
80        .tag_name
81        .strip_prefix(&spec.tag_prefix)
82        .unwrap_or(&release.tag_name)
83        .to_owned();
84    let sha256 = asset_digest(&spec, &release, asset_name).await?;
85
86    Ok(Json(ReleaseManifest {
87        version,
88        sha256,
89        size: asset.size,
90        notes_url: release.html_url.clone(),
91    }))
92}
93
94pub async fn download(
95    jwt_extractor: Arc<JwtContextExtractor>,
96    headers: HeaderMap,
97    Path(platform): Path<String>,
98) -> Result<Response, (StatusCode, String)> {
99    authenticate(&jwt_extractor, &headers).await?;
100    let spec = releases_spec()?;
101
102    let asset_name = spec.assets.get(&platform).ok_or_else(|| {
103        (
104            StatusCode::NOT_FOUND,
105            format!("no published build for platform {platform}"),
106        )
107    })?;
108
109    let release = resolve_release(&spec).await?;
110    let asset = release
111        .assets
112        .iter()
113        .find(|a| a.name == *asset_name)
114        .ok_or_else(|| {
115            (
116                StatusCode::NOT_FOUND,
117                format!("release {} has no asset {asset_name}", release.tag_name),
118            )
119        })?;
120
121    // Why: `Accept: application/octet-stream` on the asset *API* url is what
122    // makes GitHub serve bytes — without it the response is JSON metadata.
123    let upstream = github(&spec, &asset.url)
124        .header(header::ACCEPT, "application/octet-stream")
125        .send()
126        .await
127        .map_err(|e| (StatusCode::BAD_GATEWAY, format!("asset fetch failed: {e}")))?;
128
129    if !upstream.status().is_success() {
130        return Err((
131            StatusCode::BAD_GATEWAY,
132            format!("asset fetch returned {}", upstream.status()),
133        ));
134    }
135
136    // Why: streamed rather than buffered — these are tens of megabytes and the
137    // gateway must not hold one per updating client in memory.
138    let body = Body::from_stream(upstream.bytes_stream());
139    Ok((
140        StatusCode::OK,
141        [
142            (header::CONTENT_TYPE, "application/octet-stream".to_owned()),
143            (
144                header::CONTENT_DISPOSITION,
145                format!("attachment; filename=\"{asset_name}\""),
146            ),
147        ],
148        body,
149    )
150        .into_response())
151}
152
153async fn authenticate(
154    jwt_extractor: &Arc<JwtContextExtractor>,
155    headers: &HeaderMap,
156) -> Result<(), (StatusCode, String)> {
157    let credential = extract_credential(headers).ok_or_else(|| {
158        (
159            StatusCode::UNAUTHORIZED,
160            "Missing Authorization or x-api-key credential".to_owned(),
161        )
162    })?;
163    jwt_extractor
164        .decode_for_gateway(&JwtToken::new(credential))
165        .await
166        .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
167    Ok(())
168}
169
170fn releases_spec() -> Result<BridgeReleasesSpec, (StatusCode, String)> {
171    let services = ServicesBootstrap::get().map_err(|e| {
172        (
173            StatusCode::SERVICE_UNAVAILABLE,
174            format!("Services config not ready: {e}"),
175        )
176    })?;
177    services
178        .gateway_config()
179        .and_then(|g| g.bridge_releases.clone())
180        .ok_or_else(|| {
181            (
182                StatusCode::NOT_FOUND,
183                "bridge releases are not configured on this gateway".to_owned(),
184            )
185        })
186}