Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate ota to slices First leaf-module migration. OTA handlers now extract only the slices they use instead of the whole State<AppState>: - most handlers: State<PgPool> - upload/download: + State<AppStorage> - confirm: + State<AppStorage> + State<Scanning> - updater_check: + State<Config> Decouple the two helpers from AppState: verify_app_owner takes &PgPool; enqueue_ota_artifact_scan takes &PgPool + Option<&Arc<ScanPipeline>>. build_runner adapts its call site. Add require_s3/require_synckit_s3/require_public_s3 to AppStorage so storage-slice handlers keep the "or ServiceUnavailable" behavior; AppState's versions now delegate. No behavior change; 28 ota integration tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 19:02 UTC
Commit: 67b93ca96b3912d0a0a67a363784de8c6637b97c
Parent: 81ffb5c
3 files changed, +82 insertions, -53 deletions
@@ -369,7 +369,7 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) {
369 369 Ok(artifact) => {
370 370 if let Some(owner_id) = owner_id
371 371 && let Err(e) = crate::routes::ota::enqueue_ota_artifact_scan(
372 - state, artifact.id, s3_key, owner_id, file_size,
372 + &state.db, state.scanner.as_ref(), artifact.id, s3_key, owner_id, file_size,
373 373 )
374 374 .await
375 375 {
@@ -328,6 +328,29 @@ pub struct AppStateParts {
328 328 pub bg: background::BackgroundTx,
329 329 }
330 330
331 + impl AppStorage {
332 + /// Get the main S3 storage backend, or error if not configured.
333 + pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
334 + self.s3
335 + .as_ref()
336 + .ok_or_else(|| error::AppError::ServiceUnavailable("File storage is not configured".to_string()))
337 + }
338 +
339 + /// Get the SyncKit S3 storage backend, or error if not configured.
340 + pub fn require_synckit_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
341 + self.synckit_s3
342 + .as_ref()
343 + .ok_or_else(|| error::AppError::ServiceUnavailable("SyncKit storage is not configured".to_string()))
344 + }
345 +
346 + /// Get the public (CDN-served) S3 storage backend, or error if not configured.
347 + pub fn require_public_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
348 + self.public_s3
349 + .as_ref()
350 + .ok_or_else(|| error::AppError::ServiceUnavailable("Public storage bucket is not configured".to_string()))
351 + }
352 + }
353 +
331 354 impl AppState {
332 355 /// Assemble an [`AppState`] from its externally-wired dependencies.
333 356 ///
@@ -376,23 +399,17 @@ impl AppState {
376 399
377 400 /// Get the main S3 storage backend, or error if not configured.
378 401 pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
379 - self.storage.s3
380 - .as_ref()
381 - .ok_or_else(|| error::AppError::ServiceUnavailable("File storage is not configured".to_string()))
402 + self.storage.require_s3()
382 403 }
383 404
384 405 /// Get the SyncKit S3 storage backend, or error if not configured.
385 406 pub fn require_synckit_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
386 - self.storage.synckit_s3
387 - .as_ref()
388 - .ok_or_else(|| error::AppError::ServiceUnavailable("SyncKit storage is not configured".to_string()))
407 + self.storage.require_synckit_s3()
389 408 }
390 409
391 410 /// Get the public (CDN-served) S3 storage backend, or error if not configured.
392 411 pub fn require_public_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
393 - self.storage.public_s3
394 - .as_ref()
395 - .ok_or_else(|| error::AppError::ServiceUnavailable("Public storage bucket is not configured".to_string()))
412 + self.storage.require_public_s3()
396 413 }
397 414
398 415 /// Delete a user account and purge every derived in-memory cache keyed to it.
@@ -15,13 +15,19 @@ use chrono::{DateTime, Utc};
15 15 use serde::{Deserialize, Serialize};
16 16 use tower_governor::GovernorLayer;
17 17
18 + use std::sync::Arc;
19 +
20 + use sqlx::PgPool;
21 +
18 22 use crate::{
23 + config::Config,
19 24 constants,
20 25 csrf::{delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip, CsrfRouter},
21 26 db::{self, OtaReleaseId, SyncAppId},
22 27 error::{AppError, Result},
28 + scanning::ScanPipeline,
23 29 synckit_auth::SyncUser,
24 - AppState,
30 + AppState, AppStorage, Scanning,
25 31 };
26 32
27 33 // ── Validation ──
@@ -95,11 +101,11 @@ fn validate_semver(version: &str) -> Result<semver::Version> {
95 101
96 102 /// Verify the authenticated user owns the given sync app.
97 103 async fn verify_app_owner(
98 - state: &AppState,
104 + db: &PgPool,
99 105 sync_user: &SyncUser,
100 106 app_id: SyncAppId,
101 107 ) -> Result<db::DbSyncApp> {
102 - let app = db::synckit::get_sync_app_by_id(&state.db, app_id)
108 + let app = db::synckit::get_sync_app_by_id(db, app_id)
103 109 .await?
104 110 .ok_or(AppError::NotFound)?;
105 111
@@ -179,15 +185,15 @@ struct TauriUpdaterResponse {
179 185 /// `PUT /api/sync/ota/apps/{app_id}/slug`
180 186 #[tracing::instrument(skip_all, name = "ota::set_slug")]
181 187 async fn set_slug(
182 - State(state): State<AppState>,
188 + State(db): State<PgPool>,
183 189 sync_user: SyncUser,
184 190 Path(app_id): Path<SyncAppId>,
185 191 Json(req): Json<SetSlugRequest>,
186 192 ) -> Result<impl IntoResponse> {
187 - verify_app_owner(&state, &sync_user, app_id).await?;
193 + verify_app_owner(&db, &sync_user, app_id).await?;
188 194 validate_slug(&req.slug)?;
189 195
190 - db::ota::set_app_slug(&state.db, app_id, &req.slug).await?;
196 + db::ota::set_app_slug(&db, app_id, &req.slug).await?;
191 197
192 198 Ok(axum::http::StatusCode::NO_CONTENT)
193 199 }
@@ -197,12 +203,12 @@ async fn set_slug(
197 203 /// `POST /api/sync/ota/apps/{app_id}/releases`
198 204 #[tracing::instrument(skip_all, name = "ota::create_release")]
199 205 async fn create_release(
200 - State(state): State<AppState>,
206 + State(db): State<PgPool>,
201 207 sync_user: SyncUser,
202 208 Path(app_id): Path<SyncAppId>,
203 209 Json(req): Json<CreateReleaseRequest>,
204 210 ) -> Result<impl IntoResponse> {
205 - verify_app_owner(&state, &sync_user, app_id).await?;
211 + verify_app_owner(&db, &sync_user, app_id).await?;
206 212 validate_semver(&req.version)?;
207 213
208 214 // The signature is served verbatim to the Tauri updater, which verifies the
@@ -218,7 +224,7 @@ async fn create_release(
218 224 }
219 225
220 226 let release =
221 - db::ota::create_release(&state.db, app_id, &req.version, &req.notes, signature)
227 + db::ota::create_release(&db, app_id, &req.version, &req.notes, signature)
222 228 .await?;
223 229
224 230 Ok((
@@ -232,13 +238,13 @@ async fn create_release(
232 238 /// `GET /api/sync/ota/apps/{app_id}/releases`
233 239 #[tracing::instrument(skip_all, name = "ota::list_releases")]
234 240 async fn list_releases(
235 - State(state): State<AppState>,
241 + State(db): State<PgPool>,
236 242 sync_user: SyncUser,
237 243 Path(app_id): Path<SyncAppId>,
238 244 ) -> Result<impl IntoResponse> {
239 - verify_app_owner(&state, &sync_user, app_id).await?;
245 + verify_app_owner(&db, &sync_user, app_id).await?;
240 246
241 - let releases = db::ota::list_releases(&state.db, app_id).await?;
247 + let releases = db::ota::list_releases(&db, app_id).await?;
242 248 let response: Vec<ReleaseResponse> = releases.into_iter().map(ReleaseResponse::from).collect();
243 249
244 250 Ok(Json(response))
@@ -249,14 +255,14 @@ async fn list_releases(
249 255 /// `DELETE /api/sync/ota/apps/{app_id}/releases/{release_id}`
250 256 #[tracing::instrument(skip_all, name = "ota::delete_release")]
251 257 async fn delete_release_handler(
252 - State(state): State<AppState>,
258 + State(db): State<PgPool>,
253 259 sync_user: SyncUser,
254 260 Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>,
255 261 ) -> Result<impl IntoResponse> {
256 - verify_app_owner(&state, &sync_user, app_id).await?;
262 + verify_app_owner(&db, &sync_user, app_id).await?;
257 263
258 264 // Get artifact S3 keys (also verifies release belongs to this app)
259 - let s3_keys = db::ota::get_release_artifact_keys(&state.db, app_id, release_id)
265 + let s3_keys = db::ota::get_release_artifact_keys(&db, app_id, release_id)
260 266 .await?
261 267 .ok_or(AppError::NotFound)?;
262 268
@@ -267,9 +273,9 @@ async fn delete_release_handler(
267 273 // sanctioned S3 deleter, and its is_s3_key_live guard makes the reverse case
268 274 // (enqueue succeeds, delete fails) safe.
269 275 let enqueue_keys: Vec<(String, String)> = s3_keys.iter().map(|k| (k.clone(), "synckit".to_string())).collect();
270 - db::pending_s3_deletions::enqueue_deletions(&state.db, &enqueue_keys, "delete_release").await?;
276 + db::pending_s3_deletions::enqueue_deletions(&db, &enqueue_keys, "delete_release").await?;
271 277
272 - db::ota::delete_release(&state.db, release_id).await?;
278 + db::ota::delete_release(&db, release_id).await?;
273 279
274 280 Ok(axum::http::StatusCode::NO_CONTENT)
275 281 }
@@ -279,14 +285,15 @@ async fn delete_release_handler(
279 285 /// `POST /api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts`
280 286 #[tracing::instrument(skip_all, name = "ota::upload_artifact")]
281 287 async fn upload_artifact(
282 - State(state): State<AppState>,
288 + State(db): State<PgPool>,
289 + State(storage): State<AppStorage>,
283 290 sync_user: SyncUser,
284 291 Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>,
285 292 Json(req): Json<UploadArtifactRequest>,
286 293 ) -> Result<impl IntoResponse> {
287 294 // Ownership check (side effect); the app object itself is no longer needed to
288 295 // build the key now that artifacts land at a random staging key.
289 - verify_app_owner(&state, &sync_user, app_id).await?;
296 + verify_app_owner(&db, &sync_user, app_id).await?;
290 297 validate_target(&req.target)?;
291 298 validate_arch(&req.arch)?;
292 299
@@ -295,7 +302,7 @@ async fn upload_artifact(
295 302 }
296 303
297 304 // Verify the release belongs to this app (scoped lookup, not a full list scan).
298 - db::ota::get_release(&state.db, app_id, release_id)
305 + db::ota::get_release(&db, app_id, release_id)
299 306 .await?
300 307 .ok_or(AppError::NotFound)?;
301 308
@@ -305,10 +312,10 @@ async fn upload_artifact(
305 312 // pointer on re-upload — so the object no longer needs a deterministic name.
306 313 let s3_key = crate::storage::S3Client::generate_staging_key("artifact.bin");
307 314
308 - let synckit_s3 = state.require_synckit_s3()?;
315 + let synckit_s3 = storage.require_synckit_s3()?;
309 316
310 317 // Track the pending upload so the reaper can clean it up if never uploaded
311 - db::pending_uploads::record_pending_upload(&state.db, sync_user.user_id, &s3_key, "synckit").await?;
318 + db::pending_uploads::record_pending_upload(&db, sync_user.user_id, &s3_key, "synckit").await?;
312 319
313 320 let upload_url = synckit_s3
314 321 .presign_upload(
@@ -321,7 +328,7 @@ async fn upload_artifact(
321 328 .await?;
322 329
323 330 // Record the artifact in the DB
324 - db::ota::create_artifact(&state.db, release_id, &req.target, &req.arch, &s3_key, req.file_size)
331 + db::ota::create_artifact(&db, release_id, &req.target, &req.arch, &s3_key, req.file_size)
325 332 .await?;
326 333
327 334 Ok((
@@ -336,19 +343,20 @@ async fn upload_artifact(
336 343 /// so the artifact is marked clean immediately — mirroring the item path's
337 344 /// disabled-scanner branch.
338 345 pub(crate) async fn enqueue_ota_artifact_scan(
339 - state: &AppState,
346 + db: &PgPool,
347 + scanner: Option<&Arc<ScanPipeline>>,
340 348 artifact_id: db::OtaArtifactId,
341 349 s3_key: &str,
342 350 user_id: db::UserId,
343 351 file_size: i64,
344 352 ) -> Result<()> {
345 - if state.scanner.is_none() {
346 - db::ota::update_artifact_scan_status(&state.db, artifact_id, db::FileScanStatus::Clean)
353 + if scanner.is_none() {
354 + db::ota::update_artifact_scan_status(db, artifact_id, db::FileScanStatus::Clean)
347 355 .await?;
348 356 return Ok(());
349 357 }
350 358 db::scan_jobs::enqueue(
351 - &state.db,
359 + db,
352 360 db::scan_jobs::ScanTargetKind::OtaArtifact,
353 361 *artifact_id.as_uuid(),
354 362 s3_key,
@@ -374,23 +382,25 @@ struct ConfirmArtifactRequest {
374 382 /// artifact stays `pending` and is neither advertised nor downloadable.
375 383 #[tracing::instrument(skip_all, name = "ota::confirm_artifact")]
376 384 async fn confirm_artifact(
377 - State(state): State<AppState>,
385 + State(db): State<PgPool>,
386 + State(storage): State<AppStorage>,
387 + State(scanning): State<Scanning>,
378 388 sync_user: SyncUser,
379 389 Path((app_id, release_id)): Path<(SyncAppId, OtaReleaseId)>,
380 390 Json(req): Json<ConfirmArtifactRequest>,
381 391 ) -> Result<impl IntoResponse> {
382 - let _app = verify_app_owner(&state, &sync_user, app_id).await?;
392 + let _app = verify_app_owner(&db, &sync_user, app_id).await?;
383 393 validate_target(&req.target)?;
384 394 validate_arch(&req.arch)?;
385 395
386 - db::ota::get_release(&state.db, app_id, release_id)
396 + db::ota::get_release(&db, app_id, release_id)
387 397 .await?
388 398 .ok_or(AppError::NotFound)?;
389 - let artifact = db::ota::get_artifact(&state.db, release_id, &req.target, &req.arch)
399 + let artifact = db::ota::get_artifact(&db, release_id, &req.target, &req.arch)
390 400 .await?
391 401 .ok_or(AppError::NotFound)?;
392 402
393 - let synckit_s3 = state.require_synckit_s3()?;
403 + let synckit_s3 = storage.require_synckit_s3()?;
394 404 let size = synckit_s3
395 405 .object_size(&artifact.s3_key)
396 406 .await?
@@ -400,7 +410,7 @@ async fn confirm_artifact(
400 410 )
401 411 })?;
402 412
403 - enqueue_ota_artifact_scan(&state, artifact.id, &artifact.s3_key, sync_user.user_id, size as i64)
413 + enqueue_ota_artifact_scan(&db, scanning.scanner.as_ref(), artifact.id, &artifact.s3_key, sync_user.user_id, size as i64)
404 414 .await?;
405 415
406 416 Ok(axum::http::StatusCode::ACCEPTED)
@@ -416,7 +426,8 @@ async fn confirm_artifact(
416 426 /// or 204 if the client is up to date.
417 427 #[tracing::instrument(skip_all, name = "ota::updater_check")]
418 428 async fn updater_check(
419 - State(state): State<AppState>,
429 + State(db): State<PgPool>,
430 + State(config): State<Config>,
420 431 Path((slug, target, arch, current_version)): Path<(String, String, String, String)>,
421 432 ) -> Result<impl IntoResponse> {
422 433 validate_target(&target)?;
@@ -424,11 +435,11 @@ async fn updater_check(
424 435
425 436 let current = validate_semver(&current_version)?;
426 437
427 - let app = db::ota::get_app_by_slug(&state.db, &slug)
438 + let app = db::ota::get_app_by_slug(&db, &slug)
428 439 .await?
429 440 .ok_or(AppError::NotFound)?;
430 441
431 - let latest = match db::ota::get_latest_release(&state.db, app.id).await? {
442 + let latest = match db::ota::get_latest_release(&db, app.id).await? {
432 443 Some(r) => r,
433 444 None => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()),
434 445 };
@@ -445,7 +456,7 @@ async fn updater_check(
445 456 // Check that a scanned-clean artifact exists for this target/arch. A pending
446 457 // or quarantined artifact is treated as "no update available" (204) — never
447 458 // advertise a binary the scan pipeline hasn't cleared.
448 - let artifact = match db::ota::get_artifact(&state.db, latest.id, &target, &arch).await? {
459 + let artifact = match db::ota::get_artifact(&db, latest.id, &target, &arch).await? {
449 460 Some(a) => a,
450 461 None => return Ok(axum::http::StatusCode::NO_CONTENT.into_response()),
451 462 };
@@ -455,7 +466,7 @@ async fn updater_check(
455 466
456 467 let download_url = format!(
457 468 "{}/api/sync/ota/{}/download/{}/{}/{}",
458 - state.config.host_url, slug, latest.id, target, arch
469 + config.host_url, slug, latest.id, target, arch
459 470 );
460 471
461 472 Ok(Json(TauriUpdaterResponse {
@@ -473,26 +484,27 @@ async fn updater_check(
473 484 /// `GET /api/sync/ota/{slug}/download/{release_id}/{target}/{arch}`
474 485 #[tracing::instrument(skip_all, name = "ota::artifact_download")]
475 486 async fn artifact_download(
476 - State(state): State<AppState>,
487 + State(db): State<PgPool>,
488 + State(storage): State<AppStorage>,
477 489 Path((slug, release_id, target, arch)): Path<(String, OtaReleaseId, String, String)>,
478 490 ) -> Result<impl IntoResponse> {
479 491 validate_target(&target)?;
480 492 validate_arch(&arch)?;
481 493
482 494 // Verify slug resolves to an active app
483 - let app = db::ota::get_app_by_slug(&state.db, &slug)
495 + let app = db::ota::get_app_by_slug(&db, &slug)
484 496 .await?
485 497 .ok_or(AppError::NotFound)?;
486 498
487 499 // Verify release belongs to this app (scoped lookup, not a full list scan)
488 - if db::ota::get_release(&state.db, app.id, release_id)
500 + if db::ota::get_release(&db, app.id, release_id)
489 501 .await?
490 502 .is_none()
491 503 {
492 504 return Err(AppError::NotFound);
493 505 }
494 506
495 - let artifact = db::ota::get_artifact(&state.db, release_id, &target, &arch)
507 + let artifact = db::ota::get_artifact(&db, release_id, &target, &arch)
496 508 .await?
497 509 .ok_or(AppError::NotFound)?;
498 510
@@ -501,7 +513,7 @@ async fn artifact_download(
501 513 return Err(AppError::NotFound);
502 514 }
503 515
504 - let synckit_s3 = state.require_synckit_s3()?;
516 + let synckit_s3 = storage.require_synckit_s3()?;
505 517
506 518 // The artifact row is written at presign time (before the client PUTs the
507 519 // object), so an abandoned upload leaves a row pointing at an object that never