Skip to main content

max / makenotwork

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