Skip to main content

systemprompt_api/routes/gateway/
bridge_release.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_config::ProfileBootstrap;
21use systemprompt_identifiers::JwtToken;
22use systemprompt_models::profile::BridgeReleasesSpec;
23
24use super::messages::extract_credential;
25use crate::services::middleware::JwtContextExtractor;
26
27// Why: GitHub caps a release listing at 100, and bridge releases are infrequent
28// enough that the newest matching tag is always well inside the first page.
29const RELEASE_PAGE_SIZE: u8 = 30;
30
31#[derive(Debug, Deserialize)]
32pub struct LatestQuery {
33    pub platform: String,
34}
35
36/// Mirrors `ReleaseManifest` in the bridge's gateway client.
37///
38/// Keep the two in lockstep: this is a wire contract with an already-shipped
39/// binary, so a renamed field silently breaks every bridge in the field.
40#[derive(Debug, Serialize)]
41pub struct ReleaseManifest {
42    pub version: String,
43    pub sha256: String,
44    pub size: u64,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub notes_url: Option<String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct GhRelease {
51    tag_name: String,
52    #[serde(default)]
53    html_url: Option<String>,
54    #[serde(default)]
55    draft: bool,
56    #[serde(default)]
57    prerelease: bool,
58    #[serde(default)]
59    assets: Vec<GhAsset>,
60}
61
62#[derive(Debug, Deserialize)]
63struct GhAsset {
64    name: String,
65    url: String,
66    #[serde(default)]
67    size: u64,
68}
69
70pub async fn latest(
71    jwt_extractor: Arc<JwtContextExtractor>,
72    headers: HeaderMap,
73    Query(query): Query<LatestQuery>,
74) -> Result<Json<ReleaseManifest>, (StatusCode, String)> {
75    authenticate(&jwt_extractor, &headers).await?;
76    let spec = releases_spec()?;
77
78    let asset_name = spec.assets.get(&query.platform).ok_or_else(|| {
79        (
80            StatusCode::NOT_FOUND,
81            format!("no published build for platform {}", query.platform),
82        )
83    })?;
84
85    let release = resolve_release(&spec).await?;
86    let asset = release
87        .assets
88        .iter()
89        .find(|a| a.name == *asset_name)
90        .ok_or_else(|| {
91            (
92                StatusCode::NOT_FOUND,
93                format!("release {} has no asset {asset_name}", release.tag_name),
94            )
95        })?;
96
97    let version = release
98        .tag_name
99        .strip_prefix(&spec.tag_prefix)
100        .unwrap_or(&release.tag_name)
101        .to_owned();
102    let sha256 = asset_digest(&spec, &release, asset_name).await?;
103
104    Ok(Json(ReleaseManifest {
105        version,
106        sha256,
107        size: asset.size,
108        notes_url: release.html_url.clone(),
109    }))
110}
111
112pub async fn download(
113    jwt_extractor: Arc<JwtContextExtractor>,
114    headers: HeaderMap,
115    Path(platform): Path<String>,
116) -> Result<Response, (StatusCode, String)> {
117    authenticate(&jwt_extractor, &headers).await?;
118    let spec = releases_spec()?;
119
120    let asset_name = spec.assets.get(&platform).ok_or_else(|| {
121        (
122            StatusCode::NOT_FOUND,
123            format!("no published build for platform {platform}"),
124        )
125    })?;
126
127    let release = resolve_release(&spec).await?;
128    let asset = release
129        .assets
130        .iter()
131        .find(|a| a.name == *asset_name)
132        .ok_or_else(|| {
133            (
134                StatusCode::NOT_FOUND,
135                format!("release {} has no asset {asset_name}", release.tag_name),
136            )
137        })?;
138
139    // Why: `Accept: application/octet-stream` on the asset *API* url is what
140    // makes GitHub serve bytes — without it the response is JSON metadata.
141    let upstream = github(&spec, &asset.url)
142        .header(header::ACCEPT, "application/octet-stream")
143        .send()
144        .await
145        .map_err(|e| (StatusCode::BAD_GATEWAY, format!("asset fetch failed: {e}")))?;
146
147    if !upstream.status().is_success() {
148        return Err((
149            StatusCode::BAD_GATEWAY,
150            format!("asset fetch returned {}", upstream.status()),
151        ));
152    }
153
154    // Why: streamed rather than buffered — these are tens of megabytes and the
155    // gateway must not hold one per updating client in memory.
156    let body = Body::from_stream(upstream.bytes_stream());
157    Ok((
158        StatusCode::OK,
159        [
160            (header::CONTENT_TYPE, "application/octet-stream".to_owned()),
161            (
162                header::CONTENT_DISPOSITION,
163                format!("attachment; filename=\"{asset_name}\""),
164            ),
165        ],
166        body,
167    )
168        .into_response())
169}
170
171async fn authenticate(
172    jwt_extractor: &Arc<JwtContextExtractor>,
173    headers: &HeaderMap,
174) -> Result<(), (StatusCode, String)> {
175    let credential = extract_credential(headers).ok_or_else(|| {
176        (
177            StatusCode::UNAUTHORIZED,
178            "Missing Authorization or x-api-key credential".to_owned(),
179        )
180    })?;
181    jwt_extractor
182        .decode_for_gateway(&JwtToken::new(credential))
183        .await
184        .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
185    Ok(())
186}
187
188fn releases_spec() -> Result<BridgeReleasesSpec, (StatusCode, String)> {
189    let profile = ProfileBootstrap::get().map_err(|e| {
190        (
191            StatusCode::SERVICE_UNAVAILABLE,
192            format!("Profile not ready: {e}"),
193        )
194    })?;
195    profile
196        .gateway
197        .as_ref()
198        .and_then(systemprompt_models::profile::GatewayState::resolved)
199        .and_then(|g| g.bridge_releases.clone())
200        .ok_or_else(|| {
201            (
202                StatusCode::NOT_FOUND,
203                "bridge releases are not configured on this gateway".to_owned(),
204            )
205        })
206}
207
208// Why: bridge releases are tagged separately from the server's, so an
209// unfiltered "latest release" would pick the wrong one.
210async fn resolve_release(spec: &BridgeReleasesSpec) -> Result<GhRelease, (StatusCode, String)> {
211    if let Some(pinned) = spec.pinned_version.as_deref() {
212        let tag = format!("{}{pinned}", spec.tag_prefix);
213        let url = format!(
214            "https://api.github.com/repos/{}/releases/tags/{tag}",
215            spec.repo
216        );
217        return fetch_json::<GhRelease>(spec, &url).await;
218    }
219
220    let url = format!(
221        "https://api.github.com/repos/{}/releases?per_page={RELEASE_PAGE_SIZE}",
222        spec.repo
223    );
224    let releases = fetch_json::<Vec<GhRelease>>(spec, &url).await?;
225    releases
226        .into_iter()
227        .find(|r| !r.draft && !r.prerelease && r.tag_name.starts_with(&spec.tag_prefix))
228        .ok_or_else(|| {
229            (
230                StatusCode::NOT_FOUND,
231                format!("no {}* release found in {}", spec.tag_prefix, spec.repo),
232            )
233        })
234}
235
236// Why: taken from the release's cosign-signed SHA256SUMS rather than computed
237// here, so the digest the updater enforces is the one signed at publish time.
238async fn asset_digest(
239    spec: &BridgeReleasesSpec,
240    release: &GhRelease,
241    asset_name: &str,
242) -> Result<String, (StatusCode, String)> {
243    let sums = release
244        .assets
245        .iter()
246        .find(|a| a.name == "SHA256SUMS")
247        .ok_or_else(|| {
248            (
249                StatusCode::BAD_GATEWAY,
250                format!("release {} publishes no SHA256SUMS", release.tag_name),
251            )
252        })?;
253
254    let body = github(spec, &sums.url)
255        .header(header::ACCEPT, "application/octet-stream")
256        .send()
257        .await
258        .map_err(|e| (StatusCode::BAD_GATEWAY, format!("SHA256SUMS fetch: {e}")))?
259        .text()
260        .await
261        .map_err(|e| (StatusCode::BAD_GATEWAY, format!("SHA256SUMS read: {e}")))?;
262
263    parse_sha256sums(&body, asset_name).ok_or_else(|| {
264        (
265            StatusCode::BAD_GATEWAY,
266            format!("SHA256SUMS has no entry for {asset_name}"),
267        )
268    })
269}
270
271// Why: `sha256sum` output is `<hex>␠[␠*]<name>` — the second space or the `*`
272// marks binary mode, and both forms appear in the files this reads.
273pub fn parse_sha256sums(body: &str, asset_name: &str) -> Option<String> {
274    body.lines().find_map(|line| {
275        let (digest, name) = line.split_once(char::is_whitespace)?;
276        let name = name.trim_start_matches([' ', '*']);
277        (name == asset_name && digest.len() == 64).then(|| digest.to_ascii_lowercase())
278    })
279}
280
281async fn fetch_json<T: serde::de::DeserializeOwned>(
282    spec: &BridgeReleasesSpec,
283    url: &str,
284) -> Result<T, (StatusCode, String)> {
285    let resp = github(spec, url)
286        .send()
287        .await
288        .map_err(|e| (StatusCode::BAD_GATEWAY, format!("github request: {e}")))?;
289    if !resp.status().is_success() {
290        return Err((
291            StatusCode::BAD_GATEWAY,
292            format!("github returned {} for {url}", resp.status()),
293        ));
294    }
295    resp.json::<T>()
296        .await
297        .map_err(|e| (StatusCode::BAD_GATEWAY, format!("github decode: {e}")))
298}
299
300fn github(spec: &BridgeReleasesSpec, url: &str) -> reqwest::RequestBuilder {
301    let mut req = reqwest::Client::new()
302        .get(url)
303        // Why: GitHub rejects requests that send no User-Agent.
304        .header(header::USER_AGENT, "systemprompt-gateway")
305        .header("X-GitHub-Api-Version", "2022-11-28");
306    if let Some(token) = spec
307        .token_env
308        .as_deref()
309        .and_then(|k| std::env::var(k).ok())
310    {
311        req = req.bearer_auth(token);
312    }
313    req
314}