//! OTA (Over-The-Air) update endpoints for Tauri-compatible auto-updates. //! //! Management endpoints use SyncKit JWT auth (app owner only). //! Public endpoints (updater check, artifact download) are unauthenticated. //! //! See also: `/docs/developer/ota` use axum::{ Json, extract::{Path, State}, response::IntoResponse, routing::{get, post}, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tower_governor::GovernorLayer; use std::sync::Arc; use sqlx::PgPool; use crate::{ AppState, AppStorage, Scanning, config::Config, constants, csrf::{CsrfRouter, delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip}, db::{self, OtaReleaseId, SyncAppId}, error::{AppError, Result}, scanning::ScanPipeline, synckit_auth::SyncUser, }; // --- Validation --- /// Allowed target operating systems. const ALLOWED_TARGETS: &[&str] = &["linux", "darwin", "windows"]; /// Allowed CPU architectures. const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"]; /// Validate an app slug: 3-40 chars, lowercase alphanumeric + hyphens, /// no leading/trailing hyphens. /// /// Also exposed as `validate_slug_public` for the session-auth slug endpoint. fn validate_slug(slug: &str) -> Result<()> { if slug.len() < 3 || slug.len() > 40 { return Err(AppError::BadRequest( "Slug must be 3-40 characters".to_string(), )); } let bytes = slug.as_bytes(); if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' { return Err(AppError::BadRequest( "Slug cannot start or end with a hyphen".to_string(), )); } if !slug .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(AppError::BadRequest( "Slug must contain only lowercase letters, digits, and hyphens".to_string(), )); } Ok(()) } fn validate_target(target: &str) -> Result<()> { if !ALLOWED_TARGETS.contains(&target) { return Err(AppError::BadRequest(format!( "Invalid target '{}'. Allowed: {}", target, ALLOWED_TARGETS.join(", ") ))); } Ok(()) } fn validate_arch(arch: &str) -> Result<()> { if !ALLOWED_ARCHS.contains(&arch) { return Err(AppError::BadRequest(format!( "Invalid arch '{}'. Allowed: {}", arch, ALLOWED_ARCHS.join(", ") ))); } Ok(()) } fn validate_semver(version: &str) -> Result { semver::Version::parse(version).map_err(|_| { AppError::BadRequest(format!( "Invalid semver version '{version}'. Expected format: X.Y.Z" )) }) } /// Verify the authenticated user owns the given sync app. async fn verify_app_owner( db: &PgPool, sync_user: &SyncUser, app_id: SyncAppId, ) -> Result { let app = db::synckit::get_sync_app_by_id(db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != sync_user.user_id { return Err(AppError::Forbidden); } Ok(app) } // --- Request/Response types --- #[derive(Deserialize)] struct SetSlugRequest { slug: String, } #[derive(Deserialize)] struct CreateReleaseRequest { version: String, #[serde(default)] notes: String, } #[derive(Serialize)] struct ReleaseResponse { id: OtaReleaseId, version: String, notes: String, pub_date: DateTime, created_at: DateTime, } impl From for ReleaseResponse { fn from(r: db::DbOtaRelease) -> Self { Self { id: r.id, version: r.version, notes: r.notes, pub_date: r.pub_date, created_at: r.created_at, } } } #[derive(Deserialize)] struct UploadArtifactRequest { target: String, arch: String, file_size: i64, /// The artifact's minisign signature (per-file: Tauri signs each platform /// independently). Served verbatim to the updater for this target/arch. #[serde(default)] signature: String, } #[derive(Serialize)] struct UploadArtifactResponse { upload_url: String, s3_key: String, } /// Tauri-compatible updater response (returned when an update is available). #[derive(Serialize)] struct TauriUpdaterResponse { version: String, url: String, signature: String, notes: String, pub_date: String, } // --- Management endpoints (SyncKit JWT auth) --- /// Set the URL slug for a sync app. /// /// `PUT /api/sync/ota/apps/{app_id}/slug` #[tracing::instrument(skip_all, name = "ota::set_slug")] async fn set_slug( State(db): State, sync_user: SyncUser, Path(app_id): Path, Json(req): Json, ) -> Result { verify_app_owner(&db, &sync_user, app_id).await?; validate_slug(&req.slug)?; db::ota::set_app_slug(&db, app_id, &req.slug).await?; Ok(axum::http::StatusCode::NO_CONTENT) } /// Create a new OTA release. /// /// `POST /api/sync/ota/apps/{app_id}/releases` #[tracing::instrument(skip_all, name = "ota::create_release")] async fn create_release( State(db): State, sync_user: SyncUser, Path(app_id): Path, Json(req): Json, ) -> Result { verify_app_owner(&db, &sync_user, app_id).await?; validate_semver(&req.version)?; // The signature is per-artifact now (Tauri signs each platform's file // independently), so it is supplied when uploading each artifact, not here. let release = db::ota::create_release(&db, app_id, &req.version, &req.notes).await?; Ok(( axum::http::StatusCode::CREATED, Json(ReleaseResponse::from(release)), )) } /// List all releases for an app. /// /// `GET /api/sync/ota/apps/{app_id}/releases` #[tracing::instrument(skip_all, name = "ota::list_releases")] async fn list_releases( State(db): State, sync_user: SyncUser, Path(app_id): Path, ) -> Result { verify_app_owner(&db, &sync_user, app_id).await?; let releases = db::ota::list_releases(&db, app_id).await?; let response: Vec = releases.into_iter().map(ReleaseResponse::from).collect(); Ok(Json(response)) } /// Delete a release and its artifacts. /// /// `DELETE /api/sync/ota/apps/{app_id}/releases/{release_id}` #[tracing::instrument(skip_all, name = "ota::delete_release")] async fn delete_release_handler( State(db): State, sync_user: SyncUser, Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>, ) -> Result { verify_app_owner(&db, &sync_user, app_id).await?; // Get artifact S3 keys (also verifies release belongs to this app) let s3_keys = db::ota::get_release_artifact_keys(&db, app_id, release_id) .await? .ok_or(AppError::NotFound)?; // Enqueue keys as the sole durable deletion path, BEFORE the CASCADE delete. // Abort on enqueue failure rather than warn-and-proceed: deleting the rows // anyway would orphan every artifact object in the synckit bucket with no // record (ultra-fuzz Run 12 Storage F3). The queue worker is the only // sanctioned S3 deleter, and its is_s3_key_live guard makes the reverse case // (enqueue succeeds, delete fails) safe. let enqueue_keys: Vec<(String, String)> = s3_keys .iter() .map(|k| (k.clone(), "synckit".to_string())) .collect(); db::pending_s3_deletions::enqueue_deletions(&db, &enqueue_keys, "delete_release").await?; db::ota::delete_release(&db, release_id).await?; Ok(axum::http::StatusCode::NO_CONTENT) } /// Upload an artifact for a release. Returns a presigned S3 upload URL. /// /// `POST /api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts` #[tracing::instrument(skip_all, name = "ota::upload_artifact")] async fn upload_artifact( State(db): State, State(storage): State, sync_user: SyncUser, Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>, Json(req): Json, ) -> Result { // Ownership check (side effect); the app object itself is no longer needed to // build the key now that artifacts land at a random staging key. verify_app_owner(&db, &sync_user, app_id).await?; validate_target(&req.target)?; validate_arch(&req.arch)?; if req.file_size <= 0 { return Err(AppError::BadRequest( "file_size must be positive".to_string(), )); } // The signature is served verbatim to the Tauri updater, which verifies the // artifact against it with minisign. An empty/implausible signature produces // an artifact the updater advertises but can never install. Reject it here // rather than shipping an un-installable update. A real base64-encoded // minisign signature is well over 40 chars. let signature = req.signature.trim(); if signature.len() < 40 { return Err(AppError::BadRequest( "signature is required (base64-encoded minisign signature)".to_string(), )); } // Verify the release belongs to this app (scoped lookup, not a full list scan). db::ota::get_release(&db, app_id, release_id) .await? .ok_or(AppError::NotFound)?; // Staging key (unserved); the scan worker promotes it to the content key on a // Clean verdict (C1). The artifact ROW stays singleton per // (release, target, arch), `create_artifact` below overwrites its `s3_key` // pointer on re-upload, so the object no longer needs a deterministic name. let s3_key = crate::storage::S3Client::generate_staging_key("artifact.bin"); let synckit_s3 = storage.require_synckit_s3()?; // Track the pending upload so the reaper can clean it up if never uploaded db::pending_uploads::record_pending_upload(&db, sync_user.user_id, &s3_key, "synckit").await?; let upload_url = synckit_s3 .presign_upload( &s3_key, "application/octet-stream", Some(constants::OTA_PRESIGN_EXPIRY_SECS), None, None, ) .await?; // Record the artifact in the DB db::ota::create_artifact( &db, release_id, &req.target, &req.arch, &s3_key, req.file_size, signature, ) .await?; Ok(( axum::http::StatusCode::CREATED, Json(UploadArtifactResponse { upload_url, s3_key: s3_key.into_string(), }), )) } /// Enqueue (or resolve) the malware scan for an OTA artifact. Only a `clean` /// artifact is ever advertised or downloaded, so this is what un-gates a /// release. With no scanner configured, OTA uploaders are app-owners (trusted), /// so the artifact is marked clean immediately, mirroring the item path's /// disabled-scanner branch. pub(crate) async fn enqueue_ota_artifact_scan( db: &PgPool, scanner: Option<&Arc>, artifact_id: db::OtaArtifactId, s3_key: &str, user_id: db::UserId, file_size: i64, ) -> Result<()> { if scanner.is_none() { db::ota::update_artifact_scan_status(db, artifact_id, db::FileScanStatus::Clean).await?; return Ok(()); } db::scan_jobs::enqueue( db, db::scan_jobs::ScanTargetKind::OtaArtifact, *artifact_id.as_uuid(), s3_key, crate::storage::FileType::Download, user_id, file_size, ) .await?; Ok(()) } #[derive(Deserialize)] struct ConfirmArtifactRequest { target: String, arch: String, } /// Confirm an uploaded artifact: verify the object landed, then enqueue its scan. /// /// `POST /api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm` /// /// The CLI calls this after the S3 PUT. Until the scan completes clean, the /// artifact stays `pending` and is neither advertised nor downloadable. #[tracing::instrument(skip_all, name = "ota::confirm_artifact")] async fn confirm_artifact( State(db): State, State(storage): State, State(scanning): State, sync_user: SyncUser, Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>, Json(req): Json, ) -> Result { let _app = verify_app_owner(&db, &sync_user, app_id).await?; validate_target(&req.target)?; validate_arch(&req.arch)?; db::ota::get_release(&db, app_id, release_id) .await? .ok_or(AppError::NotFound)?; let artifact = db::ota::get_artifact(&db, release_id, &req.target, &req.arch) .await? .ok_or(AppError::NotFound)?; let synckit_s3 = storage.require_synckit_s3()?; let size = synckit_s3 .object_size(&artifact.s3_key) .await? .ok_or_else(|| { AppError::BadRequest( "artifact object not found in storage; upload it before confirming".to_string(), ) })?; enqueue_ota_artifact_scan( &db, scanning.scanner.as_ref(), artifact.id, &artifact.s3_key, sync_user.user_id, size as i64, ) .await?; Ok(axum::http::StatusCode::ACCEPTED) } // --- Public endpoints (no auth) --- /// Tauri updater check endpoint. /// /// `GET /api/sync/ota/{slug}/{target}/{arch}/{current_version}` /// /// Returns 200 with Tauri-compatible JSON if a newer version is available, /// or 204 if the client is up to date. #[tracing::instrument(skip_all, name = "ota::updater_check")] async fn updater_check( State(db): State, State(config): State, Path((slug, target, arch, current_version)): Path<(String, String, String, String)>, ) -> Result { validate_target(&target)?; validate_arch(&arch)?; let current = validate_semver(¤t_version)?; let app = db::ota::get_app_by_slug(&db, &slug) .await? .ok_or(AppError::NotFound)?; let Some(latest) = db::ota::get_latest_release(&db, app.id).await? else { return Ok(axum::http::StatusCode::NO_CONTENT.into_response()); }; let Ok(latest_ver) = semver::Version::parse(&latest.version) else { return Ok(axum::http::StatusCode::NO_CONTENT.into_response()); }; if latest_ver <= current { return Ok(axum::http::StatusCode::NO_CONTENT.into_response()); } // Check that a scanned-clean artifact exists for this target/arch. A pending // or quarantined artifact is treated as "no update available" (204), never // advertise a binary the scan pipeline hasn't cleared. let Some(artifact) = db::ota::get_artifact(&db, latest.id, &target, &arch).await? else { return Ok(axum::http::StatusCode::NO_CONTENT.into_response()); }; if artifact.scan_status != db::FileScanStatus::Clean { return Ok(axum::http::StatusCode::NO_CONTENT.into_response()); } let download_url = format!( "{}/api/sync/ota/{}/download/{}/{}/{}", config.host_url, slug, latest.id, target, arch ); Ok(Json(TauriUpdaterResponse { version: latest.version, url: download_url, // Per-artifact signature: this platform's file was signed independently, // so serve its own signature, not a shared release-level one. signature: artifact.signature, notes: latest.notes, pub_date: latest.pub_date.to_rfc3339(), }) .into_response()) } /// Artifact download; redirects to a presigned S3 URL. /// /// `GET /api/sync/ota/{slug}/download/{release_id}/{target}/{arch}` #[tracing::instrument(skip_all, name = "ota::artifact_download")] async fn artifact_download( State(db): State, State(storage): State, Path((slug, release_id, target, arch)): Path<(String, OtaReleaseId, String, String)>, ) -> Result { validate_target(&target)?; validate_arch(&arch)?; // Verify slug resolves to an active app let app = db::ota::get_app_by_slug(&db, &slug) .await? .ok_or(AppError::NotFound)?; // Verify release belongs to this app (scoped lookup, not a full list scan) if db::ota::get_release(&db, app.id, release_id) .await? .is_none() { return Err(AppError::NotFound); } let artifact = db::ota::get_artifact(&db, release_id, &target, &arch) .await? .ok_or(AppError::NotFound)?; // Never hand out a URL to an artifact the scan pipeline hasn't cleared. if artifact.scan_status != db::FileScanStatus::Clean { return Err(AppError::NotFound); } let synckit_s3 = storage.require_synckit_s3()?; // The artifact row is written at presign time (before the client PUTs the // object), so an abandoned upload leaves a row pointing at an object that never // landed. Verify the object exists before handing out a presigned URL: 404 // cleanly here rather than redirecting the updater to a URL that 404s at S3 // (ultra-fuzz Run 12 Storage F2). if !synckit_s3.object_exists(&artifact.s3_key).await? { tracing::warn!(%release_id, %target, %arch, "OTA artifact row present but object missing (abandoned upload?)"); return Err(AppError::NotFound); } let download_url = synckit_s3 .presign_download( &crate::storage::S3Key::from_stored(&artifact.s3_key), Some(constants::OTA_PRESIGN_EXPIRY_SECS), ) .await?; Ok(( axum::http::StatusCode::FOUND, [(axum::http::header::LOCATION, download_url)], )) } // --- Router --- /// Build the OTA route tree. /// /// Management routes use SyncKit JWT auth, rate-limited at write tier. /// Public routes (updater check, download) are unauthenticated, rate-limited at read tier. pub fn ota_routes() -> CsrfRouter { let write_rate_limit = crate::helpers::rate_limiter_ms( constants::OTA_WRITE_RATE_LIMIT_MS, constants::OTA_WRITE_RATE_LIMIT_BURST, ); const OTA_SKIP: &str = "synckit OTA: bearer auth, no session"; let mgmt_routes = CsrfRouter::new() .route( "/api/sync/ota/apps/{app_id}/slug", put_csrf_skip(OTA_SKIP, set_slug), ) .route( "/api/v1/sync/ota/apps/{app_id}/slug", put_csrf_skip(OTA_SKIP, set_slug), ) .route( "/api/sync/ota/apps/{app_id}/releases", with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)), ) .route( "/api/v1/sync/ota/apps/{app_id}/releases", with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)), ) .route( "/api/sync/ota/apps/{app_id}/releases/{release_id}", delete_csrf_skip(OTA_SKIP, delete_release_handler), ) .route( "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}", delete_csrf_skip(OTA_SKIP, delete_release_handler), ) .route( "/api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts", post_csrf_skip(OTA_SKIP, upload_artifact), ) .route( "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts", post_csrf_skip(OTA_SKIP, upload_artifact), ) .route( "/api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm", post_csrf_skip(OTA_SKIP, confirm_artifact), ) .route( "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts/confirm", post_csrf_skip(OTA_SKIP, confirm_artifact), ) .route_layer(GovernorLayer::new(write_rate_limit)); let read_rate_limit = crate::helpers::rate_limiter_ms( constants::OTA_READ_RATE_LIMIT_MS, constants::OTA_READ_RATE_LIMIT_BURST, ); let public_routes = CsrfRouter::new() .route_get( "/api/sync/ota/{slug}/{target}/{arch}/{current_version}", get(updater_check), ) .route_get( "/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}", get(updater_check), ) .route_get( "/api/sync/ota/{slug}/download/{release_id}/{target}/{arch}", get(artifact_download), ) .route_get( "/api/v1/sync/ota/{slug}/download/{release_id}/{target}/{arch}", get(artifact_download), ) .route_layer(GovernorLayer::new(read_rate_limit)); mgmt_routes.merge(public_routes) } /// Public slug validation for use by session-auth endpoints. pub fn validate_slug_public(slug: &str) -> Result<()> { validate_slug(slug) } #[cfg(test)] mod tests { use super::*; /// The Tauri updater plugin reads exactly these five top-level fields /// out of the manifest JSON. Renaming any of them silently breaks every /// installed app (Tauri logs "failed to deserialize updater response" /// and stays on the old version). Pin the contract. #[test] fn tauri_updater_response_json_shape_is_stable() { let resp = TauriUpdaterResponse { version: "0.4.1".into(), url: "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64".into(), signature: "untrusted comment: signature from minisign\nRWS...==".into(), notes: "Bug fixes".into(), pub_date: "2026-06-01T00:00:00+00:00".into(), }; let v: serde_json::Value = serde_json::to_value(&resp).unwrap(); // Top-level keys, in the order Tauri's deserializer expects them. let keys: Vec<&str> = v .as_object() .unwrap() .keys() .map(std::string::String::as_str) .collect(); assert_eq!( keys, vec!["version", "url", "signature", "notes", "pub_date"], "TauriUpdaterResponse field names/order changed, every installed Tauri app will stop updating", ); // Type spot-checks: all strings, no surprise nesting. assert!(v["version"].is_string()); assert!(v["url"].is_string()); assert!(v["signature"].is_string()); assert!(v["notes"].is_string()); assert!(v["pub_date"].is_string()); } #[test] fn tauri_updater_response_signature_is_inline_string() { // Architectural assertion: the signature rides INSIDE the manifest // JSON, not as a separate .sig sidecar file in S3. The launchplan // briefly described it as a sidecar; that was wrong. Locking the // architecture in so it doesn't drift back. let resp = TauriUpdaterResponse { version: "0.4.1".into(), url: "https://example".into(), signature: "RWS=".into(), notes: String::new(), pub_date: "2026-06-01T00:00:00Z".into(), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains(r#""signature":"RWS=""#)); } }