//! 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::{ extract::{Path, State}, response::IntoResponse, routing::{get, post}, Json, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tower_governor::GovernorLayer; use crate::{ constants, csrf::{delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip, CsrfRouter}, db::{self, OtaReleaseId, SyncAppId}, error::{AppError, Result}, synckit_auth::SyncUser, AppState, }; // ── 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 '{}'. Expected format: X.Y.Z", version )) }) } /// Verify the authenticated user owns the given sync app. async fn verify_app_owner( state: &AppState, sync_user: &SyncUser, app_id: SyncAppId, ) -> Result { let app = db::synckit::get_sync_app_by_id(&state.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, #[serde(default)] signature: String, } #[derive(Serialize)] struct ReleaseResponse { id: OtaReleaseId, version: String, notes: String, signature: 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, signature: r.signature, pub_date: r.pub_date, created_at: r.created_at, } } } #[derive(Deserialize)] struct UploadArtifactRequest { target: String, arch: String, file_size: i64, } #[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(state): State, sync_user: SyncUser, Path(app_id): Path, Json(req): Json, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; validate_slug(&req.slug)?; db::ota::set_app_slug(&state.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(state): State, sync_user: SyncUser, Path(app_id): Path, Json(req): Json, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; validate_semver(&req.version)?; let release = db::ota::create_release(&state.db, app_id, &req.version, &req.notes, &req.signature) .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(state): State, sync_user: SyncUser, Path(app_id): Path, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; let releases = db::ota::list_releases(&state.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(state): State, sync_user: SyncUser, Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>, ) -> Result { verify_app_owner(&state, &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(&state.db, app_id, release_id) .await? .ok_or(AppError::NotFound)?; // Enqueue keys as a durable safety net let enqueue_keys: Vec<(String, String)> = s3_keys.iter().map(|k| (k.clone(), "synckit".to_string())).collect(); if let Err(e) = db::pending_s3_deletions::enqueue_deletions(&state.db, &enqueue_keys, "delete_release").await { tracing::warn!(error = ?e, "failed to enqueue S3 deletions for release artifacts"); } // Clean up S3 artifacts before deleting the DB records (best-effort) if let Some(synckit_s3) = state.synckit_s3.as_ref() { for key in &s3_keys { let _ = synckit_s3.delete_object(key).await; } } db::ota::delete_release(&state.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(state): State, sync_user: SyncUser, Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>, Json(req): Json, ) -> Result { let app = verify_app_owner(&state, &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())); } // Verify the release belongs to this app let releases = db::ota::list_releases(&state.db, app_id).await?; let release = releases .iter() .find(|r| r.id == release_id) .ok_or(AppError::NotFound)?; let s3_key = format!( "ota/{}/{}/{}/{}/artifact", app.id, release.version, req.target, req.arch ); let synckit_s3 = state.require_synckit_s3()?; // Track the pending upload so the reaper can clean it up if never uploaded db::pending_uploads::record_pending_upload(&state.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(&state.db, release_id, &req.target, &req.arch, &s3_key, req.file_size) .await?; Ok(( axum::http::StatusCode::CREATED, Json(UploadArtifactResponse { upload_url, s3_key }), )) } // ── 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(state): 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(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let latest = match db::ota::get_latest_release(&state.db, app.id).await? { Some(r) => r, None => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()), }; let latest_ver = match semver::Version::parse(&latest.version) { Ok(v) => v, Err(_) => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()), }; if latest_ver <= current { return Ok(axum::http::StatusCode::NO_CONTENT.into_response()); } // Check that an artifact exists for this target/arch let artifact = match db::ota::get_artifact(&state.db, latest.id, &target, &arch).await? { Some(a) => a, None => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()), }; let _ = artifact; let download_url = format!( "{}/api/sync/ota/{}/download/{}/{}/{}", state.config.host_url, slug, latest.id, target, arch ); Ok(Json(TauriUpdaterResponse { version: latest.version, url: download_url, signature: latest.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(state): 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(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; // Verify release belongs to this app let releases = db::ota::list_releases(&state.db, app.id).await?; if !releases.iter().any(|r| r.id == release_id) { return Err(AppError::NotFound); } let artifact = db::ota::get_artifact(&state.db, release_id, &target, &arch) .await? .ok_or(AppError::NotFound)?; let synckit_s3 = state.require_synckit_s3()?; let download_url = synckit_s3 .presign_download(&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_layer(GovernorLayer { config: 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 { config: 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(|s| s.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=""#)); } }