Skip to main content

max / makenotwork

31.8 KB · 893 lines History Blame Raw
1 //! SyncKit push/pull, status, device management, and key management endpoints.
2
3 use axum::{
4 Json,
5 extract::{Path, State},
6 http::StatusCode,
7 response::{IntoResponse, Response},
8 };
9 use serde_json::json;
10
11 use sqlx::PgPool;
12
13 use crate::{
14 config::Config,
15 constants,
16 db::{self, SyncDeviceId},
17 error::{AppError, Result},
18 payments::{self, SyncBillingInterval},
19 synckit_auth::SyncUser,
20 validation,
21 };
22
23 use super::{
24 AppPricingRequest, AppPricingResponse, BeginRotationRequest, BeginRotationResponse,
25 CompleteRotationErrorResponse, CompleteRotationRequest, GetKeyResponse, PendingKeyInfo,
26 PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse, PutKeyRequest,
27 RegisterDeviceRequest, RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest,
28 RotationEntriesResponse, RotationEntry, SyncAccountResponse, SyncCapChangeRequest,
29 SyncCheckoutResponse, SyncDeviceResponse, SyncQuoteRequest, SyncQuoteResponse,
30 SyncStatusResponse, SyncSubscribeRequest, SyncSubscriptionStatusResponse,
31 };
32
33 // ── Sync endpoints (JWT auth) ──
34
35 /// Push encrypted changelog entries from a device.
36 #[utoipa::path(post, path = "/api/v1/sync/push", tag = "SyncKit",
37 request_body = PushRequest,
38 responses((status = 200, description = "New cursor position", body = PushResponse)),
39 security(("bearer" = [])),
40 )]
41 #[tracing::instrument(skip_all, name = "synckit::sync_push", fields(app_id, user_id))]
42 pub(super) async fn sync_push(
43 State(db): State<PgPool>,
44 State(sync): State<crate::Sync>,
45 sync_user: SyncUser,
46 Json(req): Json<PushRequest>,
47 ) -> Result<Response> {
48 let app_id = sync_user.app_id;
49 let user_id = sync_user.user_id;
50 tracing::Span::current().record("app_id", tracing::field::display(&app_id));
51 tracing::Span::current().record("user_id", tracing::field::display(&user_id));
52
53 // Paid-only sync: first-party apps require an active end-user subscription
54 // to write. Reads (pull) stay open so a lapsed user can still export their
55 // data. Non-internal (developer-billed) apps are always allowed here.
56 if !db::synckit::internal_write_allowed(&db, app_id, user_id).await? {
57 return Ok((
58 StatusCode::PAYMENT_REQUIRED,
59 Json(json!({ "reason": "no_subscription" })),
60 )
61 .into_response());
62 }
63
64 if req.changes.is_empty() {
65 return Err(AppError::BadRequest("No changes provided".to_string()));
66 }
67 if req.changes.len() > constants::SYNCKIT_PUSH_MAX_CHANGES {
68 return Err(AppError::BadRequest(format!(
69 "Maximum {} changes per push",
70 constants::SYNCKIT_PUSH_MAX_CHANGES
71 )));
72 }
73
74 // Validate all changes
75 for change in &req.changes {
76 validation::validate_sync_table_name(&change.table)?;
77 validation::validate_sync_row_id(&change.row_id)?;
78 if change.op == db::SyncOperation::Delete && change.data.is_some() {
79 return Err(AppError::BadRequest(
80 "DELETE operations should not include data".to_string(),
81 ));
82 }
83 }
84
85 // Verify device belongs to this user + app (indexed point lookup, not a
86 // full device fetch + linear scan).
87 if !db::synckit::sync_device_belongs(&db, req.device_id, app_id, user_id).await? {
88 return Err(AppError::BadRequest("Unknown device".to_string()));
89 }
90
91 db::synckit::touch_sync_device(&db, req.device_id).await?;
92
93 // Build change tuples (op converted to string for TEXT column)
94 let changes: Vec<_> = req
95 .changes
96 .iter()
97 .map(|c| {
98 (
99 c.table.clone(),
100 c.op.to_string(),
101 c.row_id.clone(),
102 c.timestamp,
103 c.data.clone(),
104 )
105 })
106 .collect();
107
108 let cursor =
109 db::synckit::push_sync_changes(&db, app_id, user_id, req.device_id, req.batch_id, &changes)
110 .await?;
111
112 // Notify SSE subscribers, carrying the new max seq so a device already at
113 // (or past) this cursor can skip a redundant pull, avoids the thundering
114 // herd where every subscriber pulls on every push.
115 sync.notify_push(app_id, user_id, cursor);
116
117 Ok(Json(PushResponse { cursor }).into_response())
118 }
119
120 /// Pull changelog entries after a given cursor.
121 #[utoipa::path(post, path = "/api/v1/sync/pull", tag = "SyncKit",
122 request_body = PullRequest,
123 responses((status = 200, description = "Changes since cursor", body = PullResponse)),
124 security(("bearer" = [])),
125 )]
126 #[tracing::instrument(skip_all, name = "synckit::sync_pull", fields(app_id, user_id))]
127 pub(super) async fn sync_pull(
128 State(db): State<PgPool>,
129 sync_user: SyncUser,
130 headers: axum::http::HeaderMap,
131 Json(req): Json<PullRequest>,
132 ) -> Result<impl IntoResponse> {
133 let app_id = sync_user.app_id;
134 let user_id = sync_user.user_id;
135 tracing::Span::current().record("app_id", tracing::field::display(&app_id));
136 tracing::Span::current().record("user_id", tracing::field::display(&user_id));
137
138 // Verify device belongs to this user + app (indexed point lookup, not a
139 // full device fetch + linear scan).
140 if !db::synckit::sync_device_belongs(&db, req.device_id, app_id, user_id).await? {
141 return Err(AppError::BadRequest("Unknown device".to_string()));
142 }
143
144 // Validate table name filters if provided
145 if let Some(ref tables) = req.tables {
146 if tables.len() > 50 {
147 return Err(AppError::BadRequest(
148 "Maximum 50 table names per filter".to_string(),
149 ));
150 }
151 for table in tables {
152 validation::validate_sync_table_name(table)?;
153 }
154 }
155
156 let page_size = constants::SYNCKIT_PULL_PAGE_SIZE;
157 let entries = db::synckit::pull_sync_changes_filtered(
158 &db,
159 app_id,
160 user_id,
161 req.cursor,
162 page_size,
163 req.tables.as_deref(),
164 req.since,
165 )
166 .await?;
167
168 let has_more = entries.len() as i64 == page_size;
169 let new_cursor = entries.last().map_or(req.cursor, |e| e.seq);
170
171 // Mark the device seen and advance its compaction cursor in one statement.
172 // GREATEST keeps the cursor monotonic even if `new_cursor == req.cursor`.
173 db::synckit::touch_and_advance_cursor(
174 &db,
175 req.device_id,
176 new_cursor,
177 super::client_version(&headers).as_deref(),
178 )
179 .await?;
180
181 let changes: Vec<PullChangeEntry> = entries
182 .into_iter()
183 .map(|e| PullChangeEntry {
184 seq: e.seq,
185 device_id: e.device_id,
186 table: e.table_name,
187 op: e.operation.to_string(),
188 row_id: e.row_id,
189 timestamp: e.client_timestamp,
190 data: e.data,
191 key_id: e.key_id,
192 })
193 .collect();
194
195 Ok(Json(PullResponse {
196 changes,
197 cursor: new_cursor,
198 has_more,
199 }))
200 }
201
202 /// Return sync metadata for the authenticated user and app.
203 #[utoipa::path(get, path = "/api/v1/sync/status", tag = "SyncKit",
204 responses((status = 200, description = "Sync status", body = SyncStatusResponse)),
205 security(("bearer" = [])),
206 )]
207 #[tracing::instrument(skip_all, name = "synckit::sync_status")]
208 pub(super) async fn sync_status(
209 State(db): State<PgPool>,
210 sync_user: SyncUser,
211 ) -> Result<impl IntoResponse> {
212 let app_id = sync_user.app_id;
213 let user_id = sync_user.user_id;
214
215 let (total_changes, latest_cursor) = db::synckit::get_sync_status(&db, app_id, user_id).await?;
216
217 Ok(Json(SyncStatusResponse {
218 total_changes,
219 latest_cursor,
220 }))
221 }
222
223 /// Return the authenticated user's email and username, for the app to display
224 /// "logged in as ..." in its sync UI.
225 #[utoipa::path(get, path = "/api/v1/sync/account", tag = "SyncKit",
226 responses((status = 200, description = "Account info", body = SyncAccountResponse)),
227 security(("bearer" = [])),
228 )]
229 #[tracing::instrument(skip_all, name = "synckit::sync_account")]
230 pub(super) async fn sync_account(
231 State(db): State<PgPool>,
232 sync_user: SyncUser,
233 ) -> Result<impl IntoResponse> {
234 let user = db::users::get_user_by_id(&db, sync_user.user_id)
235 .await?
236 .ok_or(AppError::NotFound)?;
237
238 Ok(Json(SyncAccountResponse {
239 email: user.email.as_str().to_string(),
240 username: user.username.as_str().to_string(),
241 }))
242 }
243
244 /// Return the authenticated user's subscription status for this app.
245 /// Returns `active: false` and `None` fields when the user has no subscription
246 /// (rather than 404) so clients can render a "subscribe" CTA uniformly.
247 #[utoipa::path(get, path = "/api/v1/sync/subscription", tag = "SyncKit",
248 responses((status = 200, description = "Subscription status", body = SyncSubscriptionStatusResponse)),
249 security(("bearer" = [])),
250 )]
251 #[tracing::instrument(skip_all, name = "synckit::sync_subscription_status")]
252 pub(super) async fn sync_subscription_status(
253 State(db): State<PgPool>,
254 sync_user: SyncUser,
255 ) -> Result<impl IntoResponse> {
256 let sub =
257 db::synckit::get_user_app_subscription(&db, sync_user.user_id, sync_user.app_id).await?;
258
259 let response = match sub {
260 Some(s) => SyncSubscriptionStatusResponse {
261 active: s.status == "active",
262 tier: Some(s.interval),
263 status: Some(s.status),
264 storage_limit_bytes: s.storage_limit_bytes,
265 pending_storage_limit_bytes: s.pending_storage_limit_bytes,
266 storage_used_bytes: None,
267 current_period_end: s.current_period_end.map(|t| t.to_rfc3339()),
268 },
269 None => SyncSubscriptionStatusResponse {
270 active: false,
271 tier: None,
272 status: None,
273 storage_limit_bytes: None,
274 pending_storage_limit_bytes: None,
275 storage_used_bytes: None,
276 current_period_end: None,
277 },
278 };
279
280 Ok(Json(response))
281 }
282
283 /// Return the pricing-formula constants for an app. The client uses these to
284 /// quote a price locally as the user adjusts the cap slider; the same formula
285 /// is enforced server-side at checkout so the client number is only advisory.
286 #[utoipa::path(post, path = "/api/v1/sync/app/pricing", tag = "SyncKit",
287 request_body = AppPricingRequest,
288 responses((status = 200, description = "Pricing formula", body = AppPricingResponse)),
289 )]
290 #[tracing::instrument(skip_all, name = "synckit::get_app_pricing")]
291 pub(super) async fn get_app_pricing(
292 State(db): State<PgPool>,
293 Json(req): Json<AppPricingRequest>,
294 ) -> Result<impl IntoResponse> {
295 let app = db::synckit::get_sync_app_by_api_key(&db, &req.api_key)
296 .await?
297 .ok_or(AppError::NotFound)?;
298
299 Ok(Json(AppPricingResponse {
300 app_name: app.name,
301 min_charge_cents: payments::MIN_CHARGE_CENTS,
302 per_gb_tenths_of_cent_per_month:
303 payments::synckit_app_pricing::PER_GB_TENTHS_OF_CENT_PER_MONTH,
304 annual_multiplier: payments::ANNUAL_MULTIPLIER,
305 min_cap_bytes: payments::MIN_CAP_BYTES,
306 max_cap_bytes: payments::MAX_CAP_BYTES,
307 }))
308 }
309
310 /// Quote the price for a (cap, interval) pair. Authenticated so clients
311 /// cannot scrape pricing without an account, but otherwise pure: the result
312 /// only depends on the formula constants returned by `app/pricing`.
313 #[utoipa::path(post, path = "/api/v1/sync/subscription/quote", tag = "SyncKit",
314 request_body = SyncQuoteRequest,
315 responses((status = 200, description = "Quoted price", body = SyncQuoteResponse)),
316 security(("bearer" = [])),
317 )]
318 #[tracing::instrument(skip_all, name = "synckit::quote_subscription_price")]
319 pub(super) async fn quote_subscription_price(
320 _sync_user: SyncUser,
321 Json(req): Json<SyncQuoteRequest>,
322 ) -> Result<impl IntoResponse> {
323 let interval = SyncBillingInterval::parse(&req.interval)?;
324 let price_cents = payments::quote_price_cents(req.cap_bytes, interval)?;
325 Ok(Json(SyncQuoteResponse {
326 cap_bytes: req.cap_bytes,
327 interval: interval.as_str().to_string(),
328 price_cents,
329 }))
330 }
331
332 /// Create a Stripe Checkout Session for subscribing this user to the app's
333 /// cloud sync at their chosen storage cap. The `app_sync_subscriptions` row
334 /// is written by the Stripe webhook on `checkout.session.completed`.
335 #[utoipa::path(post, path = "/api/v1/sync/subscription/checkout", tag = "SyncKit",
336 request_body = SyncSubscribeRequest,
337 responses((status = 200, description = "Checkout URL", body = SyncCheckoutResponse)),
338 security(("bearer" = [])),
339 )]
340 #[tracing::instrument(skip_all, name = "synckit::create_subscription_checkout")]
341 pub(super) async fn create_subscription_checkout(
342 State(db): State<PgPool>,
343 State(payments): State<crate::Billing>,
344 State(config): State<Config>,
345 sync_user: SyncUser,
346 Json(req): Json<SyncSubscribeRequest>,
347 ) -> Result<impl IntoResponse> {
348 if db::synckit::get_user_app_subscription(&db, sync_user.user_id, sync_user.app_id)
349 .await?
350 .is_some()
351 {
352 return Err(AppError::BadRequest(
353 "Already subscribed; use the storage-cap endpoint to adjust your cap".to_string(),
354 ));
355 }
356
357 let interval = SyncBillingInterval::parse(&req.interval)?;
358 let amount_cents = payments::quote_price_cents(req.cap_bytes, interval)?;
359
360 let app = db::synckit::get_sync_app_by_id(&db, sync_user.app_id)
361 .await?
362 .ok_or(AppError::NotFound)?;
363 let cap_gib = req.cap_bytes / (1024 * 1024 * 1024);
364 let product_name = format!("{} cloud sync ({} GiB)", app.name, cap_gib);
365
366 let stripe = payments
367 .stripe
368 .as_ref()
369 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
370
371 let success_url = format!("{}/sync/subscribed", config.host_url);
372 let cancel_url = format!("{}/sync/canceled", config.host_url);
373
374 let result = stripe
375 .create_synckit_app_sub_checkout_session(&crate::payments::SynckitAppSubCheckoutParams {
376 product_name: &product_name,
377 amount_cents,
378 interval: interval.as_str(),
379 user_id: sync_user.user_id,
380 app_id: sync_user.app_id,
381 storage_limit_bytes: Some(req.cap_bytes),
382 success_url: &success_url,
383 cancel_url: &cancel_url,
384 })
385 .await?;
386
387 let checkout_url = result
388 .url
389 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
390
391 Ok(Json(SyncCheckoutResponse { checkout_url }))
392 }
393
394 /// Queue a storage-cap change to take effect at the next billing cycle.
395 /// Holding the change for the cycle boundary avoids mid-cycle proration
396 /// surprises and keeps the user in control of when their bill changes.
397 ///
398 /// Updates Stripe first (re-price the subscription item with
399 /// `proration_behavior=None`), then records the pending cap in the DB. The
400 /// renewal webhook promotes the pending cap to active when Stripe rolls the
401 /// period, and because the Stripe price is already updated, the new invoice
402 /// is at the new price.
403 #[utoipa::path(post, path = "/api/v1/sync/subscription/storage-cap", tag = "SyncKit",
404 request_body = SyncCapChangeRequest,
405 responses((status = 200, description = "Pending cap recorded", body = SyncSubscriptionStatusResponse)),
406 security(("bearer" = [])),
407 )]
408 #[tracing::instrument(skip_all, name = "synckit::queue_storage_cap_change")]
409 pub(super) async fn queue_storage_cap_change(
410 State(db): State<PgPool>,
411 State(payments): State<crate::Billing>,
412 sync_user: SyncUser,
413 Json(req): Json<SyncCapChangeRequest>,
414 ) -> Result<impl IntoResponse> {
415 let sub = db::synckit::get_user_app_subscription(&db, sync_user.user_id, sync_user.app_id)
416 .await?
417 .ok_or_else(|| AppError::BadRequest("No active subscription to adjust".to_string()))?;
418
419 if req.cap_bytes < payments::MIN_CAP_BYTES || req.cap_bytes > payments::MAX_CAP_BYTES {
420 return Err(AppError::BadRequest(format!(
421 "Storage cap must be between {} and {} GiB",
422 payments::MIN_CAP_BYTES / (1024 * 1024 * 1024),
423 payments::MAX_CAP_BYTES / (1024 * 1024 * 1024)
424 )));
425 }
426
427 let interval = payments::SyncBillingInterval::parse(&sub.interval)?;
428 let new_price_cents = payments::quote_price_cents(req.cap_bytes, interval)?;
429
430 let stripe = payments
431 .stripe
432 .as_ref()
433 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
434
435 let app = db::synckit::get_sync_app_by_id(&db, sync_user.app_id)
436 .await?
437 .ok_or(AppError::NotFound)?;
438 let cap_gib = req.cap_bytes / (1024 * 1024 * 1024);
439 let product_name = format!("{} cloud sync ({} GiB)", app.name, cap_gib);
440
441 // Stripe first: if this fails, we want the DB pending cap to stay
442 // unchanged so the user isn't sitting on an upgrade they never paid for.
443 stripe
444 .update_synckit_app_sub_price(
445 &sub.stripe_subscription_id,
446 new_price_cents,
447 interval,
448 &product_name,
449 )
450 .await?;
451
452 db::synckit::set_pending_storage_cap(&db, sync_user.user_id, sync_user.app_id, req.cap_bytes)
453 .await?;
454
455 Ok(Json(SyncSubscriptionStatusResponse {
456 active: sub.status == "active",
457 tier: Some(sub.interval),
458 status: Some(sub.status),
459 storage_limit_bytes: sub.storage_limit_bytes,
460 pending_storage_limit_bytes: Some(req.cap_bytes),
461 storage_used_bytes: None,
462 current_period_end: sub.current_period_end.map(|t| t.to_rfc3339()),
463 }))
464 }
465
466 // ── Device endpoints (JWT auth) ──
467
468 /// Register a new sync device (or update an existing one by name).
469 #[utoipa::path(post, path = "/api/v1/sync/devices", tag = "SyncKit",
470 request_body = RegisterDeviceRequest,
471 responses((status = 200, description = "Registered device", body = SyncDeviceResponse)),
472 security(("bearer" = [])),
473 )]
474 #[tracing::instrument(skip_all, name = "synckit::register_device")]
475 pub(super) async fn register_device(
476 State(db): State<PgPool>,
477 sync_user: SyncUser,
478 headers: axum::http::HeaderMap,
479 Json(req): Json<RegisterDeviceRequest>,
480 ) -> Result<impl IntoResponse> {
481 validation::validate_sync_device_name(&req.device_name)?;
482
483 // Serialize concurrent registrations for this (app, user) so the
484 // count-then-upsert cap check below is race-free. The lock is transaction
485 // scoped; holding `lock_tx` to the end of the handler keeps a second
486 // concurrent registration blocked until this one's upsert commits, so it
487 // sees the updated count and can't push past the cap (Run 21 concurrency).
488 let mut lock_tx = db.begin().await?;
489 sqlx::query(
490 "SELECT pg_advisory_xact_lock(hashtextextended('synckit_device:' || $1::text || ':' || $2::text, 0))",
491 )
492 .bind(sync_user.app_id)
493 .bind(sync_user.user_id)
494 .execute(&mut *lock_tx)
495 .await?;
496
497 // Enforce device limit (upsert on existing name is fine, only new names count)
498 let count = db::synckit::count_sync_devices(&db, sync_user.app_id, sync_user.user_id).await?;
499 if count >= constants::SYNCKIT_MAX_DEVICES_PER_APP {
500 // Check if this is an existing device (upsert), allow updates
501 let existing =
502 db::synckit::get_sync_devices(&db, sync_user.app_id, sync_user.user_id).await?;
503 if !existing.iter().any(|d| d.device_name == req.device_name) {
504 return Err(AppError::BadRequest(format!(
505 "Maximum {} devices per app",
506 constants::SYNCKIT_MAX_DEVICES_PER_APP
507 )));
508 }
509 }
510
511 let device = db::synckit::upsert_sync_device(
512 &db,
513 sync_user.app_id,
514 sync_user.user_id,
515 &req.device_name,
516 req.platform,
517 super::client_version(&headers).as_deref(),
518 )
519 .await?;
520
521 Ok(Json(SyncDeviceResponse {
522 id: device.id,
523 app_id: device.app_id,
524 user_id: device.user_id,
525 device_name: device.device_name,
526 platform: device.platform.to_string(),
527 last_seen_at: device.last_seen_at,
528 created_at: device.created_at,
529 }))
530 }
531
532 /// List all devices registered for the authenticated user and app.
533 #[utoipa::path(get, path = "/api/v1/sync/devices", tag = "SyncKit",
534 responses((status = 200, description = "List of devices", body = Vec<SyncDeviceResponse>)),
535 security(("bearer" = [])),
536 )]
537 #[tracing::instrument(skip_all, name = "synckit::list_devices")]
538 pub(super) async fn list_devices(
539 State(db): State<PgPool>,
540 sync_user: SyncUser,
541 ) -> Result<impl IntoResponse> {
542 let devices = db::synckit::get_sync_devices(&db, sync_user.app_id, sync_user.user_id).await?;
543
544 let response: Vec<SyncDeviceResponse> = devices
545 .into_iter()
546 .map(|d| SyncDeviceResponse {
547 id: d.id,
548 app_id: d.app_id,
549 user_id: d.user_id,
550 device_name: d.device_name,
551 platform: d.platform.to_string(),
552 last_seen_at: d.last_seen_at,
553 created_at: d.created_at,
554 })
555 .collect();
556
557 Ok(Json(response))
558 }
559
560 /// Remove a registered device.
561 #[utoipa::path(delete, path = "/api/v1/sync/devices/{id}", tag = "SyncKit",
562 params(("id" = String, Path, description = "Device ID")),
563 responses((status = 204, description = "Device deleted"), (status = 404, description = "Device not found")),
564 security(("bearer" = [])),
565 )]
566 #[tracing::instrument(skip_all, name = "synckit::delete_device")]
567 pub(super) async fn delete_device(
568 State(db): State<PgPool>,
569 sync_user: SyncUser,
570 headers: axum::http::HeaderMap,
571 Path(device_id): Path<SyncDeviceId>,
572 ) -> Result<impl IntoResponse> {
573 let deleted =
574 db::synckit::delete_sync_device(&db, device_id, sync_user.app_id, sync_user.user_id)
575 .await?;
576
577 if !deleted {
578 return Err(AppError::NotFound);
579 }
580
581 // Security response to a device removal: invalidate the user's sync tokens
582 // so the removed device's JWT dies immediately (before this it survived to
583 // expiry), and record the event for the audit log. Best-effort, a logging
584 // or invalidation hiccup must not fail the delete the user asked for.
585 let ip = crate::helpers::extract_client_ip(&headers);
586 if let Err(e) = db::synckit::invalidate_user_sync_tokens(&db, sync_user.user_id).await {
587 tracing::error!(error = ?e, user_id = %sync_user.user_id,
588 "failed to invalidate sync tokens after device removal");
589 }
590 if let Err(e) = db::synckit::record_security_event(
591 &db,
592 sync_user.app_id,
593 Some(sync_user.user_id),
594 db::synckit::sync_security_event::DEVICE_REMOVED,
595 Some(serde_json::json!({ "device_id": device_id.to_string() })),
596 ip.as_deref(),
597 )
598 .await
599 {
600 tracing::error!(error = ?e, "failed to record device_removed security event");
601 }
602
603 Ok(axum::http::StatusCode::NO_CONTENT)
604 }
605
606 // ── Key management endpoints (JWT auth) ──
607
608 /// Store or update the user's encrypted master key envelope.
609 #[utoipa::path(put, path = "/api/v1/sync/keys", tag = "SyncKit",
610 request_body = PutKeyRequest,
611 responses((status = 204, description = "Key stored"), (status = 409, description = "Version mismatch")),
612 security(("bearer" = [])),
613 )]
614 #[tracing::instrument(skip_all, name = "synckit::put_sync_key")]
615 pub(super) async fn put_sync_key(
616 State(db): State<PgPool>,
617 sync_user: SyncUser,
618 Json(req): Json<PutKeyRequest>,
619 ) -> Result<impl IntoResponse> {
620 // Max 4 KB for the encrypted key envelope
621 if req.encrypted_key.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES {
622 return Err(AppError::BadRequest(
623 "Encrypted key exceeds 4KB limit".to_string(),
624 ));
625 }
626
627 let updated = db::synckit::upsert_sync_key(
628 &db,
629 sync_user.app_id,
630 sync_user.user_id,
631 &req.encrypted_key,
632 req.expected_version,
633 )
634 .await?;
635
636 if !updated {
637 return Err(AppError::Conflict(
638 "Key version mismatch, another device changed the password. Fetch the latest key and retry.".to_string(),
639 ));
640 }
641
642 Ok(axum::http::StatusCode::NO_CONTENT)
643 }
644
645 /// Retrieve the user's encrypted master key envelope.
646 #[utoipa::path(get, path = "/api/v1/sync/keys", tag = "SyncKit",
647 responses((status = 200, description = "Encrypted key envelope", body = GetKeyResponse), (status = 404, description = "No key stored")),
648 security(("bearer" = [])),
649 )]
650 #[tracing::instrument(skip_all, name = "synckit::get_sync_key")]
651 pub(super) async fn get_sync_key(
652 State(db): State<PgPool>,
653 sync_user: SyncUser,
654 ) -> Result<impl IntoResponse> {
655 let info = db::synckit::get_sync_key(&db, sync_user.app_id, sync_user.user_id)
656 .await?
657 .ok_or(AppError::NotFound)?;
658
659 let pending_key = info
660 .pending_key
661 .map(|(encrypted_key, key_id)| PendingKeyInfo {
662 encrypted_key,
663 key_id,
664 });
665
666 Ok(Json(GetKeyResponse {
667 encrypted_key: info.encrypted_key,
668 key_version: info.key_version,
669 key_id: info.key_id,
670 pending_key,
671 }))
672 }
673
674 // ── Key rotation endpoints ──
675
676 /// Begin a key rotation.
677 #[utoipa::path(post, path = "/api/v1/sync/keys/rotate", tag = "SyncKit",
678 request_body = BeginRotationRequest,
679 responses(
680 (status = 200, description = "Rotation started or resumed", body = BeginRotationResponse),
681 (status = 409, description = "Version mismatch or rotation in progress"),
682 ),
683 security(("bearer" = [])),
684 )]
685 #[tracing::instrument(skip_all, name = "synckit::begin_rotation")]
686 pub(super) async fn begin_rotation(
687 State(db): State<PgPool>,
688 sync_user: SyncUser,
689 Json(req): Json<BeginRotationRequest>,
690 ) -> Result<impl IntoResponse> {
691 if req.new_encrypted_key.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES {
692 return Err(AppError::BadRequest(
693 "Encrypted key exceeds 4KB limit".to_string(),
694 ));
695 }
696
697 // Verify device belongs to this user + app via an indexed point lookup, not a
698 // fetch-all-then-scan (ultra-fuzz Run 4 Perf).
699 if !db::synckit::sync_device_belongs(&db, req.device_id, sync_user.app_id, sync_user.user_id)
700 .await?
701 {
702 return Err(AppError::BadRequest("Unknown device".to_string()));
703 }
704
705 let result = db::synckit::begin_key_rotation(
706 &db,
707 sync_user.app_id,
708 sync_user.user_id,
709 req.device_id,
710 &req.new_encrypted_key,
711 req.expected_key_version,
712 )
713 .await?;
714
715 match result {
716 Ok(rotation) => Ok(Json(BeginRotationResponse {
717 rotation_id: rotation.id,
718 target_seq: rotation.target_seq,
719 new_key_id: rotation.new_key_id,
720 })
721 .into_response()),
722 Err(msg) => Err(AppError::Conflict(msg.to_string())),
723 }
724 }
725
726 /// Pull entries that need re-encryption during a rotation.
727 #[utoipa::path(post, path = "/api/v1/sync/keys/rotate/entries", tag = "SyncKit",
728 request_body = RotationEntriesRequest,
729 responses((status = 200, description = "Entries needing re-encryption", body = RotationEntriesResponse)),
730 security(("bearer" = [])),
731 )]
732 #[tracing::instrument(skip_all, name = "synckit::rotation_entries")]
733 pub(super) async fn rotation_entries(
734 State(db): State<PgPool>,
735 sync_user: SyncUser,
736 Json(req): Json<RotationEntriesRequest>,
737 ) -> Result<impl IntoResponse> {
738 let rotation = db::synckit::get_key_rotation(&db, sync_user.app_id, sync_user.user_id)
739 .await?
740 .ok_or_else(|| AppError::BadRequest("No active rotation".to_string()))?;
741
742 if rotation.id != req.rotation_id {
743 return Err(AppError::BadRequest("Rotation ID mismatch".to_string()));
744 }
745
746 let page_size = constants::SYNCKIT_ROTATION_BATCH_MAX as i64;
747 let raw_entries = db::synckit::get_rotation_entries(
748 &db,
749 sync_user.app_id,
750 sync_user.user_id,
751 rotation.new_key_id,
752 req.after_seq,
753 page_size,
754 )
755 .await?;
756
757 let has_more = raw_entries.len() as i64 == page_size;
758 let entries: Vec<RotationEntry> = raw_entries
759 .into_iter()
760 .map(|(seq, table, row_id, data)| RotationEntry {
761 seq,
762 table,
763 row_id,
764 data,
765 })
766 .collect();
767
768 Ok(Json(RotationEntriesResponse { entries, has_more }))
769 }
770
771 /// Submit a batch of re-encrypted entries during rotation.
772 #[utoipa::path(post, path = "/api/v1/sync/keys/rotate/batch", tag = "SyncKit",
773 request_body = RotationBatchRequest,
774 responses((status = 200, description = "Batch processed", body = RotationBatchResponse)),
775 security(("bearer" = [])),
776 )]
777 #[tracing::instrument(skip_all, name = "synckit::rotation_batch")]
778 pub(super) async fn rotation_batch(
779 State(db): State<PgPool>,
780 sync_user: SyncUser,
781 Json(req): Json<RotationBatchRequest>,
782 ) -> Result<impl IntoResponse> {
783 if req.entries.is_empty() {
784 return Err(AppError::BadRequest("No entries provided".to_string()));
785 }
786 if req.entries.len() > constants::SYNCKIT_ROTATION_BATCH_MAX {
787 return Err(AppError::BadRequest(format!(
788 "Maximum {} entries per batch",
789 constants::SYNCKIT_ROTATION_BATCH_MAX
790 )));
791 }
792
793 let rotation = db::synckit::get_key_rotation(&db, sync_user.app_id, sync_user.user_id)
794 .await?
795 .ok_or_else(|| AppError::BadRequest("No active rotation".to_string()))?;
796
797 if rotation.id != req.rotation_id {
798 return Err(AppError::BadRequest("Rotation ID mismatch".to_string()));
799 }
800
801 let entries: Vec<(i64, Option<serde_json::Value>)> =
802 req.entries.into_iter().map(|e| (e.seq, e.data)).collect();
803
804 let updated_count = db::synckit::submit_rotation_batch(
805 &db,
806 sync_user.app_id,
807 sync_user.user_id,
808 rotation.id,
809 rotation.new_key_id,
810 &entries,
811 )
812 .await?;
813
814 Ok(Json(RotationBatchResponse { updated_count }))
815 }
816
817 /// Complete a key rotation.
818 #[utoipa::path(post, path = "/api/v1/sync/keys/rotate/complete", tag = "SyncKit",
819 request_body = CompleteRotationRequest,
820 responses(
821 (status = 204, description = "Rotation completed"),
822 (status = 409, description = "Entries still need re-encryption"),
823 ),
824 security(("bearer" = [])),
825 )]
826 #[tracing::instrument(skip_all, name = "synckit::complete_rotation")]
827 pub(super) async fn complete_rotation(
828 State(db): State<PgPool>,
829 sync_user: SyncUser,
830 Json(req): Json<CompleteRotationRequest>,
831 ) -> Result<impl IntoResponse> {
832 let result = db::synckit::complete_key_rotation(
833 &db,
834 sync_user.app_id,
835 sync_user.user_id,
836 req.rotation_id,
837 )
838 .await?;
839
840 match result {
841 Ok(_new_key_id) => {
842 // Audit the completed rotation (best-effort).
843 if let Err(e) = db::synckit::record_security_event(
844 &db,
845 sync_user.app_id,
846 Some(sync_user.user_id),
847 db::synckit::sync_security_event::KEY_ROTATION_COMPLETED,
848 Some(serde_json::json!({ "rotation_id": req.rotation_id.to_string() })),
849 None,
850 )
851 .await
852 {
853 tracing::error!(error = ?e, "failed to record key_rotation_completed security event");
854 }
855 Ok(axum::http::StatusCode::NO_CONTENT.into_response())
856 }
857 Err(0) => Err(AppError::BadRequest("No active rotation".to_string())),
858 Err(remaining) => Ok((
859 axum::http::StatusCode::CONFLICT,
860 Json(CompleteRotationErrorResponse { remaining }),
861 )
862 .into_response()),
863 }
864 }
865
866 /// Cancel a stale rotation (>24h without activity).
867 #[utoipa::path(delete, path = "/api/v1/sync/keys/rotate", tag = "SyncKit",
868 responses(
869 (status = 204, description = "Stale rotation cancelled"),
870 (status = 404, description = "No stale rotation found"),
871 ),
872 security(("bearer" = [])),
873 )]
874 #[tracing::instrument(skip_all, name = "synckit::cancel_rotation")]
875 pub(super) async fn cancel_rotation(
876 State(db): State<PgPool>,
877 sync_user: SyncUser,
878 ) -> Result<impl IntoResponse> {
879 let cancelled = db::synckit::cancel_stale_rotation(
880 &db,
881 sync_user.app_id,
882 sync_user.user_id,
883 constants::SYNCKIT_ROTATION_STALE_HOURS,
884 )
885 .await?;
886
887 if !cancelled {
888 return Err(AppError::NotFound);
889 }
890
891 Ok(axum::http::StatusCode::NO_CONTENT)
892 }
893