Skip to main content

max / makenotwork

17.0 KB · 548 lines History Blame Raw
1 //! OTA (Over-The-Air) update endpoints for Tauri-compatible auto-updates.
2 //!
3 //! Management endpoints use SyncKit JWT auth (app owner only).
4 //! Public endpoints (updater check, artifact download) are unauthenticated.
5 //!
6 //! See also: `/docs/developer/ota`
7
8 use axum::{
9 extract::{Path, State},
10 response::IntoResponse,
11 routing::{get, post},
12 Json,
13 };
14 use chrono::{DateTime, Utc};
15 use serde::{Deserialize, Serialize};
16 use tower_governor::GovernorLayer;
17
18 use crate::{
19 constants,
20 csrf::{delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip, CsrfRouter},
21 db::{self, OtaReleaseId, SyncAppId},
22 error::{AppError, Result},
23 synckit_auth::SyncUser,
24 AppState,
25 };
26
27 // ── Validation ──
28
29 /// Allowed target operating systems.
30 const ALLOWED_TARGETS: &[&str] = &["linux", "darwin", "windows"];
31
32 /// Allowed CPU architectures.
33 const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"];
34
35 /// Validate an app slug: 3-40 chars, lowercase alphanumeric + hyphens,
36 /// no leading/trailing hyphens.
37 ///
38 /// Also exposed as `validate_slug_public` for the session-auth slug endpoint.
39 fn validate_slug(slug: &str) -> Result<()> {
40 if slug.len() < 3 || slug.len() > 40 {
41 return Err(AppError::BadRequest(
42 "Slug must be 3-40 characters".to_string(),
43 ));
44 }
45
46 let bytes = slug.as_bytes();
47 if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' {
48 return Err(AppError::BadRequest(
49 "Slug cannot start or end with a hyphen".to_string(),
50 ));
51 }
52
53 if !slug
54 .chars()
55 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
56 {
57 return Err(AppError::BadRequest(
58 "Slug must contain only lowercase letters, digits, and hyphens".to_string(),
59 ));
60 }
61
62 Ok(())
63 }
64
65 fn validate_target(target: &str) -> Result<()> {
66 if !ALLOWED_TARGETS.contains(&target) {
67 return Err(AppError::BadRequest(format!(
68 "Invalid target '{}'. Allowed: {}",
69 target,
70 ALLOWED_TARGETS.join(", ")
71 )));
72 }
73 Ok(())
74 }
75
76 fn validate_arch(arch: &str) -> Result<()> {
77 if !ALLOWED_ARCHS.contains(&arch) {
78 return Err(AppError::BadRequest(format!(
79 "Invalid arch '{}'. Allowed: {}",
80 arch,
81 ALLOWED_ARCHS.join(", ")
82 )));
83 }
84 Ok(())
85 }
86
87 fn validate_semver(version: &str) -> Result<semver::Version> {
88 semver::Version::parse(version).map_err(|_| {
89 AppError::BadRequest(format!(
90 "Invalid semver version '{}'. Expected format: X.Y.Z",
91 version
92 ))
93 })
94 }
95
96 /// Verify the authenticated user owns the given sync app.
97 async fn verify_app_owner(
98 state: &AppState,
99 sync_user: &SyncUser,
100 app_id: SyncAppId,
101 ) -> Result<db::DbSyncApp> {
102 let app = db::synckit::get_sync_app_by_id(&state.db, app_id)
103 .await?
104 .ok_or(AppError::NotFound)?;
105
106 if app.creator_id != sync_user.user_id {
107 return Err(AppError::Forbidden);
108 }
109
110 Ok(app)
111 }
112
113 // ── Request/Response types ──
114
115 #[derive(Deserialize)]
116 struct SetSlugRequest {
117 slug: String,
118 }
119
120 #[derive(Deserialize)]
121 struct CreateReleaseRequest {
122 version: String,
123 #[serde(default)]
124 notes: String,
125 #[serde(default)]
126 signature: String,
127 }
128
129 #[derive(Serialize)]
130 struct ReleaseResponse {
131 id: OtaReleaseId,
132 version: String,
133 notes: String,
134 signature: String,
135 pub_date: DateTime<Utc>,
136 created_at: DateTime<Utc>,
137 }
138
139 impl From<db::DbOtaRelease> for ReleaseResponse {
140 fn from(r: db::DbOtaRelease) -> Self {
141 Self {
142 id: r.id,
143 version: r.version,
144 notes: r.notes,
145 signature: r.signature,
146 pub_date: r.pub_date,
147 created_at: r.created_at,
148 }
149 }
150 }
151
152 #[derive(Deserialize)]
153 struct UploadArtifactRequest {
154 target: String,
155 arch: String,
156 file_size: i64,
157 }
158
159 #[derive(Serialize)]
160 struct UploadArtifactResponse {
161 upload_url: String,
162 s3_key: String,
163 }
164
165 /// Tauri-compatible updater response (returned when an update is available).
166 #[derive(Serialize)]
167 struct TauriUpdaterResponse {
168 version: String,
169 url: String,
170 signature: String,
171 notes: String,
172 pub_date: String,
173 }
174
175 // ── Management endpoints (SyncKit JWT auth) ──
176
177 /// Set the URL slug for a sync app.
178 ///
179 /// `PUT /api/sync/ota/apps/{app_id}/slug`
180 #[tracing::instrument(skip_all, name = "ota::set_slug")]
181 async fn set_slug(
182 State(state): State<AppState>,
183 sync_user: SyncUser,
184 Path(app_id): Path<SyncAppId>,
185 Json(req): Json<SetSlugRequest>,
186 ) -> Result<impl IntoResponse> {
187 verify_app_owner(&state, &sync_user, app_id).await?;
188 validate_slug(&req.slug)?;
189
190 db::ota::set_app_slug(&state.db, app_id, &req.slug).await?;
191
192 Ok(axum::http::StatusCode::NO_CONTENT)
193 }
194
195 /// Create a new OTA release.
196 ///
197 /// `POST /api/sync/ota/apps/{app_id}/releases`
198 #[tracing::instrument(skip_all, name = "ota::create_release")]
199 async fn create_release(
200 State(state): State<AppState>,
201 sync_user: SyncUser,
202 Path(app_id): Path<SyncAppId>,
203 Json(req): Json<CreateReleaseRequest>,
204 ) -> Result<impl IntoResponse> {
205 verify_app_owner(&state, &sync_user, app_id).await?;
206 validate_semver(&req.version)?;
207
208 let release =
209 db::ota::create_release(&state.db, app_id, &req.version, &req.notes, &req.signature)
210 .await?;
211
212 Ok((
213 axum::http::StatusCode::CREATED,
214 Json(ReleaseResponse::from(release)),
215 ))
216 }
217
218 /// List all releases for an app.
219 ///
220 /// `GET /api/sync/ota/apps/{app_id}/releases`
221 #[tracing::instrument(skip_all, name = "ota::list_releases")]
222 async fn list_releases(
223 State(state): State<AppState>,
224 sync_user: SyncUser,
225 Path(app_id): Path<SyncAppId>,
226 ) -> Result<impl IntoResponse> {
227 verify_app_owner(&state, &sync_user, app_id).await?;
228
229 let releases = db::ota::list_releases(&state.db, app_id).await?;
230 let response: Vec<ReleaseResponse> = releases.into_iter().map(ReleaseResponse::from).collect();
231
232 Ok(Json(response))
233 }
234
235 /// Delete a release and its artifacts.
236 ///
237 /// `DELETE /api/sync/ota/apps/{app_id}/releases/{release_id}`
238 #[tracing::instrument(skip_all, name = "ota::delete_release")]
239 async fn delete_release_handler(
240 State(state): State<AppState>,
241 sync_user: SyncUser,
242 Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>,
243 ) -> Result<impl IntoResponse> {
244 verify_app_owner(&state, &sync_user, app_id).await?;
245
246 // Get artifact S3 keys (also verifies release belongs to this app)
247 let s3_keys = db::ota::get_release_artifact_keys(&state.db, app_id, release_id)
248 .await?
249 .ok_or(AppError::NotFound)?;
250
251 // Enqueue keys as a durable safety net
252 let enqueue_keys: Vec<(String, String)> = s3_keys.iter().map(|k| (k.clone(), "synckit".to_string())).collect();
253 if let Err(e) = db::pending_s3_deletions::enqueue_deletions(&state.db, &enqueue_keys, "delete_release").await {
254 tracing::warn!(error = ?e, "failed to enqueue S3 deletions for release artifacts");
255 }
256
257 // Clean up S3 artifacts before deleting the DB records (best-effort)
258 if let Some(synckit_s3) = state.synckit_s3.as_ref() {
259 for key in &s3_keys {
260 let _ = synckit_s3.delete_object(key).await;
261 }
262 }
263
264 db::ota::delete_release(&state.db, release_id).await?;
265
266 Ok(axum::http::StatusCode::NO_CONTENT)
267 }
268
269 /// Upload an artifact for a release. Returns a presigned S3 upload URL.
270 ///
271 /// `POST /api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts`
272 #[tracing::instrument(skip_all, name = "ota::upload_artifact")]
273 async fn upload_artifact(
274 State(state): State<AppState>,
275 sync_user: SyncUser,
276 Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>,
277 Json(req): Json<UploadArtifactRequest>,
278 ) -> Result<impl IntoResponse> {
279 let app = verify_app_owner(&state, &sync_user, app_id).await?;
280 validate_target(&req.target)?;
281 validate_arch(&req.arch)?;
282
283 if req.file_size <= 0 {
284 return Err(AppError::BadRequest("file_size must be positive".to_string()));
285 }
286
287 // Verify the release belongs to this app
288 let releases = db::ota::list_releases(&state.db, app_id).await?;
289 let release = releases
290 .iter()
291 .find(|r| r.id == release_id)
292 .ok_or(AppError::NotFound)?;
293
294 let s3_key = format!(
295 "ota/{}/{}/{}/{}/artifact",
296 app.id, release.version, req.target, req.arch
297 );
298
299 let synckit_s3 = state.require_synckit_s3()?;
300
301 // Track the pending upload so the reaper can clean it up if never uploaded
302 db::pending_uploads::record_pending_upload(&state.db, sync_user.user_id, &s3_key, "synckit").await?;
303
304 let upload_url = synckit_s3
305 .presign_upload(
306 &s3_key,
307 "application/octet-stream",
308 Some(constants::OTA_PRESIGN_EXPIRY_SECS),
309 None,
310 None,
311 )
312 .await?;
313
314 // Record the artifact in the DB
315 db::ota::create_artifact(&state.db, release_id, &req.target, &req.arch, &s3_key, req.file_size)
316 .await?;
317
318 Ok((
319 axum::http::StatusCode::CREATED,
320 Json(UploadArtifactResponse { upload_url, s3_key }),
321 ))
322 }
323
324 // ── Public endpoints (no auth) ──
325
326 /// Tauri updater check endpoint.
327 ///
328 /// `GET /api/sync/ota/{slug}/{target}/{arch}/{current_version}`
329 ///
330 /// Returns 200 with Tauri-compatible JSON if a newer version is available,
331 /// or 204 if the client is up to date.
332 #[tracing::instrument(skip_all, name = "ota::updater_check")]
333 async fn updater_check(
334 State(state): State<AppState>,
335 Path((slug, target, arch, current_version)): Path<(String, String, String, String)>,
336 ) -> Result<impl IntoResponse> {
337 validate_target(&target)?;
338 validate_arch(&arch)?;
339
340 let current = validate_semver(&current_version)?;
341
342 let app = db::ota::get_app_by_slug(&state.db, &slug)
343 .await?
344 .ok_or(AppError::NotFound)?;
345
346 let latest = match db::ota::get_latest_release(&state.db, app.id).await? {
347 Some(r) => r,
348 None => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()),
349 };
350
351 let latest_ver = match semver::Version::parse(&latest.version) {
352 Ok(v) => v,
353 Err(_) => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()),
354 };
355
356 if latest_ver <= current {
357 return Ok(axum::http::StatusCode::NO_CONTENT.into_response());
358 }
359
360 // Check that an artifact exists for this target/arch
361 let artifact = match db::ota::get_artifact(&state.db, latest.id, &target, &arch).await? {
362 Some(a) => a,
363 None => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()),
364 };
365 let _ = artifact;
366
367 let download_url = format!(
368 "{}/api/sync/ota/{}/download/{}/{}/{}",
369 state.config.host_url, slug, latest.id, target, arch
370 );
371
372 Ok(Json(TauriUpdaterResponse {
373 version: latest.version,
374 url: download_url,
375 signature: latest.signature,
376 notes: latest.notes,
377 pub_date: latest.pub_date.to_rfc3339(),
378 })
379 .into_response())
380 }
381
382 /// Artifact download; redirects to a presigned S3 URL.
383 ///
384 /// `GET /api/sync/ota/{slug}/download/{release_id}/{target}/{arch}`
385 #[tracing::instrument(skip_all, name = "ota::artifact_download")]
386 async fn artifact_download(
387 State(state): State<AppState>,
388 Path((slug, release_id, target, arch)): Path<(String, OtaReleaseId, String, String)>,
389 ) -> Result<impl IntoResponse> {
390 validate_target(&target)?;
391 validate_arch(&arch)?;
392
393 // Verify slug resolves to an active app
394 let app = db::ota::get_app_by_slug(&state.db, &slug)
395 .await?
396 .ok_or(AppError::NotFound)?;
397
398 // Verify release belongs to this app
399 let releases = db::ota::list_releases(&state.db, app.id).await?;
400 if !releases.iter().any(|r| r.id == release_id) {
401 return Err(AppError::NotFound);
402 }
403
404 let artifact = db::ota::get_artifact(&state.db, release_id, &target, &arch)
405 .await?
406 .ok_or(AppError::NotFound)?;
407
408 let synckit_s3 = state.require_synckit_s3()?;
409
410 let download_url = synckit_s3
411 .presign_download(&artifact.s3_key, Some(constants::OTA_PRESIGN_EXPIRY_SECS))
412 .await?;
413
414 Ok((
415 axum::http::StatusCode::FOUND,
416 [(axum::http::header::LOCATION, download_url)],
417 ))
418 }
419
420 // ── Router ──
421
422 /// Build the OTA route tree.
423 ///
424 /// Management routes use SyncKit JWT auth, rate-limited at write tier.
425 /// Public routes (updater check, download) are unauthenticated, rate-limited at read tier.
426 pub fn ota_routes() -> CsrfRouter<AppState> {
427 let write_rate_limit = crate::helpers::rate_limiter_ms(
428 constants::OTA_WRITE_RATE_LIMIT_MS,
429 constants::OTA_WRITE_RATE_LIMIT_BURST,
430 );
431
432 const OTA_SKIP: &str = "synckit OTA: bearer auth, no session";
433 let mgmt_routes = CsrfRouter::new()
434 .route("/api/sync/ota/apps/{app_id}/slug", put_csrf_skip(OTA_SKIP, set_slug))
435 .route("/api/v1/sync/ota/apps/{app_id}/slug", put_csrf_skip(OTA_SKIP, set_slug))
436 .route(
437 "/api/sync/ota/apps/{app_id}/releases",
438 with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)),
439 )
440 .route(
441 "/api/v1/sync/ota/apps/{app_id}/releases",
442 with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)),
443 )
444 .route(
445 "/api/sync/ota/apps/{app_id}/releases/{release_id}",
446 delete_csrf_skip(OTA_SKIP, delete_release_handler),
447 )
448 .route(
449 "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}",
450 delete_csrf_skip(OTA_SKIP, delete_release_handler),
451 )
452 .route(
453 "/api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts",
454 post_csrf_skip(OTA_SKIP, upload_artifact),
455 )
456 .route(
457 "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts",
458 post_csrf_skip(OTA_SKIP, upload_artifact),
459 )
460 .route_layer(GovernorLayer {
461 config: write_rate_limit,
462 });
463
464 let read_rate_limit = crate::helpers::rate_limiter_ms(
465 constants::OTA_READ_RATE_LIMIT_MS,
466 constants::OTA_READ_RATE_LIMIT_BURST,
467 );
468
469 let public_routes = CsrfRouter::new()
470 .route_get(
471 "/api/sync/ota/{slug}/{target}/{arch}/{current_version}",
472 get(updater_check),
473 )
474 .route_get(
475 "/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}",
476 get(updater_check),
477 )
478 .route_get(
479 "/api/sync/ota/{slug}/download/{release_id}/{target}/{arch}",
480 get(artifact_download),
481 )
482 .route_get(
483 "/api/v1/sync/ota/{slug}/download/{release_id}/{target}/{arch}",
484 get(artifact_download),
485 )
486 .route_layer(GovernorLayer {
487 config: read_rate_limit,
488 });
489
490 mgmt_routes.merge(public_routes)
491 }
492
493 /// Public slug validation for use by session-auth endpoints.
494 pub fn validate_slug_public(slug: &str) -> Result<()> {
495 validate_slug(slug)
496 }
497
498 #[cfg(test)]
499 mod tests {
500 use super::*;
501
502 /// The Tauri updater plugin reads exactly these five top-level fields
503 /// out of the manifest JSON. Renaming any of them silently breaks every
504 /// installed app (Tauri logs "failed to deserialize updater response"
505 /// and stays on the old version). Pin the contract.
506 #[test]
507 fn tauri_updater_response_json_shape_is_stable() {
508 let resp = TauriUpdaterResponse {
509 version: "0.4.1".into(),
510 url: "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64".into(),
511 signature: "untrusted comment: signature from minisign\nRWS...==".into(),
512 notes: "Bug fixes".into(),
513 pub_date: "2026-06-01T00:00:00+00:00".into(),
514 };
515 let v: serde_json::Value = serde_json::to_value(&resp).unwrap();
516 // Top-level keys, in the order Tauri's deserializer expects them.
517 let keys: Vec<&str> = v.as_object().unwrap().keys().map(|s| s.as_str()).collect();
518 assert_eq!(
519 keys,
520 vec!["version", "url", "signature", "notes", "pub_date"],
521 "TauriUpdaterResponse field names/order changed — every installed Tauri app will stop updating",
522 );
523 // Type spot-checks: all strings, no surprise nesting.
524 assert!(v["version"].is_string());
525 assert!(v["url"].is_string());
526 assert!(v["signature"].is_string());
527 assert!(v["notes"].is_string());
528 assert!(v["pub_date"].is_string());
529 }
530
531 #[test]
532 fn tauri_updater_response_signature_is_inline_string() {
533 // Architectural assertion: the signature rides INSIDE the manifest
534 // JSON, not as a separate .sig sidecar file in S3. The launchplan
535 // briefly described it as a sidecar; that was wrong. Locking the
536 // architecture in so it doesn't drift back.
537 let resp = TauriUpdaterResponse {
538 version: "0.4.1".into(),
539 url: "https://example".into(),
540 signature: "RWS=".into(),
541 notes: String::new(),
542 pub_date: "2026-06-01T00:00:00Z".into(),
543 };
544 let json = serde_json::to_string(&resp).unwrap();
545 assert!(json.contains(r#""signature":"RWS=""#));
546 }
547 }
548