Skip to main content

max / makenotwork

40.3 KB · 1203 lines History Blame Raw
1 //! SyncKit cloud sync API.
2 //!
3 //! Provides push/pull changelog sync, device management, E2E encryption key
4 //! storage, and blob storage endpoints for the SyncKit client SDK. All sync
5 //! and device endpoints use JWT-based authentication (issued by the
6 //! `/api/sync/auth` endpoint), which is separate from the session-based auth
7 //! used by the rest of the MNW web application. App management endpoints
8 //! (create, list, delete apps) use the standard session auth since they are
9 //! accessed from the MNW dashboard.
10 //!
11 //! Rate limiting is applied in two tiers: a stricter per-second limit on the
12 //! auth endpoint (to prevent credential stuffing) and a per-millisecond limit
13 //! on all other sync/device/key/blob endpoints.
14 //!
15 //! See also: `/docs/developer/synckit`
16
17 pub(crate) mod apps;
18 pub(crate) mod auth;
19 pub(crate) mod billing;
20 pub(crate) mod blobs;
21 pub(crate) mod groups;
22 pub(crate) mod keys;
23 mod subscribe;
24 pub(crate) mod sync;
25
26 use axum::routing::get;
27 use chrono::{DateTime, Utc};
28 use serde::{Deserialize, Serialize};
29 use tower_governor::GovernorLayer;
30
31 use crate::{
32 AppState, constants,
33 csrf::{
34 CsrfRouter, delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf,
35 put_csrf_skip,
36 },
37 db::{self, SyncAppId, SyncDeviceId, SyncGroupId, SyncOperation, SyncPlatform, UserId},
38 };
39
40 /// Reason strings for synckit CSRF Skip routes. The auth_routes and
41 /// sync_routes blocks use server-to-server or JWT bearer auth with no
42 /// session cookie; CSRF doesn't apply. The app_routes block IS
43 /// session-authed (dashboard-driven) so those use `post_csrf` etc.
44 const SYNCKIT_API_KEY_SKIP: &str = "synckit server-to-server: api_key auth, no session";
45 const SYNCKIT_APP_SECRET_SKIP: &str =
46 "synckit server-to-server: keys-endpoint app_secret auth, no session";
47 const SYNCKIT_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session";
48
49 /// Longest client version string we will store. Matches the column width in
50 /// migration 180; a longer value is a client we don't recognise, so it is
51 /// dropped rather than truncated into something that reads like a real version.
52 const CLIENT_VERSION_MAX_LENGTH: usize = 32;
53
54 /// The SDK version out of a `synckit-client/<version>` User-Agent, if the
55 /// request carries one.
56 ///
57 /// Only the version is kept. A request from anything that is not the SDK (a
58 /// browser, curl, an older client that sends no such header) yields `None`, and
59 /// `None` is stored as-is: "syncing, version unknown" is a real answer and
60 /// guessing would corrupt the field-version readout this exists to produce.
61 /// The version is checked for shape, not parsed as semver, so a client that
62 /// adds a pre-release suffix still reports.
63 pub(crate) fn client_version(headers: &axum::http::HeaderMap) -> Option<String> {
64 let version = headers
65 .get(axum::http::header::USER_AGENT)?
66 .to_str()
67 .ok()?
68 .split_whitespace()
69 .next()?
70 .strip_prefix("synckit-client/")?;
71 let ok = !version.is_empty()
72 && version.len() <= CLIENT_VERSION_MAX_LENGTH
73 && version.starts_with(|c: char| c.is_ascii_digit())
74 && version
75 .chars()
76 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_'));
77 ok.then(|| version.to_string())
78 }
79
80 // ── Request/Response types ──
81
82 #[derive(Deserialize, utoipa::ToSchema)]
83 pub(crate) struct SyncAuthRequest {
84 pub email: String,
85 pub password: String,
86 pub api_key: String,
87 /// Developer-defined SDK key. Identifies which billing slot this session's
88 /// uploads count against. Required.
89 pub key: String,
90 }
91
92 #[derive(Serialize, utoipa::ToSchema)]
93 pub(crate) struct SyncAuthResponse {
94 token: String,
95 #[schema(value_type = String)]
96 user_id: UserId,
97 #[schema(value_type = String)]
98 app_id: SyncAppId,
99 }
100
101 #[derive(Deserialize, utoipa::ToSchema)]
102 pub(crate) struct ValidateAppQuery {
103 pub(crate) api_key: String,
104 }
105
106 #[derive(Serialize, utoipa::ToSchema)]
107 pub(crate) struct ValidateAppResponse {
108 app_name: String,
109 }
110
111 #[derive(Deserialize, utoipa::ToSchema)]
112 pub(crate) struct PushRequest {
113 #[schema(value_type = String)]
114 pub device_id: SyncDeviceId,
115 /// Client-generated UUID for idempotent push. If a push with the same
116 /// batch_id has already been committed, the server returns the existing
117 /// cursor without re-inserting.
118 pub batch_id: uuid::Uuid,
119 pub changes: Vec<ChangeEntry>,
120 }
121
122 #[derive(Deserialize, utoipa::ToSchema)]
123 pub(crate) struct ChangeEntry {
124 pub table: String,
125 #[schema(value_type = String)]
126 pub op: SyncOperation,
127 pub row_id: String,
128 #[schema(value_type = String)]
129 pub timestamp: DateTime<Utc>,
130 pub data: Option<serde_json::Value>,
131 }
132
133 #[derive(Serialize, utoipa::ToSchema)]
134 pub(crate) struct PushResponse {
135 cursor: i64,
136 }
137
138 #[derive(Deserialize, utoipa::ToSchema)]
139 pub(crate) struct PullRequest {
140 #[schema(value_type = String)]
141 pub device_id: SyncDeviceId,
142 pub cursor: i64,
143 /// Optional table name filter; only return entries for these tables.
144 #[serde(default)]
145 pub tables: Option<Vec<String>>,
146 /// Optional timestamp filter; only return entries at or after this time.
147 #[serde(default)]
148 #[schema(value_type = Option<String>)]
149 pub since: Option<DateTime<Utc>>,
150 }
151
152 #[derive(Serialize, utoipa::ToSchema)]
153 pub(crate) struct PullResponse {
154 changes: Vec<PullChangeEntry>,
155 cursor: i64,
156 has_more: bool,
157 }
158
159 #[derive(Serialize, utoipa::ToSchema)]
160 pub(crate) struct PullChangeEntry {
161 seq: i64,
162 #[schema(value_type = String)]
163 device_id: SyncDeviceId,
164 table: String,
165 op: String,
166 row_id: String,
167 #[schema(value_type = String)]
168 timestamp: DateTime<Utc>,
169 data: Option<serde_json::Value>,
170 /// Which encryption key was used. Null means key_id 1 (pre-rotation).
171 #[serde(skip_serializing_if = "Option::is_none")]
172 key_id: Option<i32>,
173 }
174
175 #[derive(Serialize, utoipa::ToSchema)]
176 pub(crate) struct SyncDeviceResponse {
177 #[schema(value_type = String)]
178 id: SyncDeviceId,
179 #[schema(value_type = String)]
180 app_id: SyncAppId,
181 #[schema(value_type = String)]
182 user_id: UserId,
183 device_name: String,
184 platform: String,
185 #[schema(value_type = String)]
186 last_seen_at: DateTime<Utc>,
187 #[schema(value_type = String)]
188 created_at: DateTime<Utc>,
189 }
190
191 #[derive(Deserialize, utoipa::ToSchema)]
192 pub(crate) struct RegisterDeviceRequest {
193 pub device_name: String,
194 #[schema(value_type = String)]
195 pub platform: SyncPlatform,
196 }
197
198 #[derive(Deserialize)]
199 pub struct CreateAppRequest {
200 pub name: String,
201 pub project_id: Option<String>,
202 pub item_id: Option<String>,
203 }
204
205 #[derive(Deserialize)]
206 pub struct UpdateAppLinkRequest {
207 pub project_id: Option<String>,
208 pub item_id: Option<String>,
209 }
210
211 #[derive(Deserialize)]
212 pub struct UpdateAppSlugRequest {
213 pub slug: String,
214 }
215
216 #[derive(Serialize, utoipa::ToSchema)]
217 pub(crate) struct SyncStatusResponse {
218 total_changes: i64,
219 latest_cursor: Option<i64>,
220 }
221
222 #[derive(Serialize, utoipa::ToSchema)]
223 pub(crate) struct SyncAccountResponse {
224 pub email: String,
225 pub username: String,
226 }
227
228 // ── Group types ──
229
230 #[derive(Deserialize, utoipa::ToSchema)]
231 pub(crate) struct CreateGroupRequest {
232 /// The group id, generated client-side. The admin seals the GCK grant bound
233 /// to this id before the group exists (the grant's AAD binds the group id), so
234 /// the id must be chosen by the client, not the server. A UUID collision (PK
235 /// conflict) is rejected.
236 #[schema(value_type = String)]
237 pub id: SyncGroupId,
238 pub name: String,
239 /// The Group Content Key sealed to the creating admin's own identity public
240 /// key (base64), opaque to the server.
241 pub admin_sealed_gck: String,
242 /// The admin's own identity public key (base64), stored so the GCK can be
243 /// re-sealed to the admin on a later rotation.
244 pub admin_pubkey: String,
245 }
246
247 #[derive(Serialize, utoipa::ToSchema)]
248 pub(crate) struct GroupResponse {
249 #[schema(value_type = String)]
250 id: SyncGroupId,
251 #[schema(value_type = String)]
252 app_id: SyncAppId,
253 #[schema(value_type = String)]
254 admin_user_id: UserId,
255 name: String,
256 gck_version: i32,
257 #[schema(value_type = String)]
258 created_at: DateTime<Utc>,
259 }
260
261 impl From<db::DbSyncGroup> for GroupResponse {
262 fn from(g: db::DbSyncGroup) -> Self {
263 Self {
264 id: g.id,
265 app_id: g.app_id,
266 admin_user_id: g.admin_user_id,
267 name: g.name,
268 gck_version: g.gck_version,
269 created_at: g.created_at,
270 }
271 }
272 }
273
274 #[derive(Deserialize, utoipa::ToSchema)]
275 pub(crate) struct AddMemberRequest {
276 /// The member's account email, resolved to a verified user server-side.
277 pub member_email: String,
278 /// The GCK sealed to the member's identity public key (base64), produced by
279 /// the admin with the group's current GCK. Opaque to the server.
280 pub sealed_gck: String,
281 /// The member's identity public key (base64), stored so the GCK can be
282 /// re-sealed to them on a later rotation.
283 pub member_pubkey: String,
284 /// Optional role: "member" (default) or "admin".
285 #[serde(default)]
286 pub role: Option<String>,
287 }
288
289 /// One member's identity public key, from `GET /groups/{id}/pubkeys`. The admin
290 /// re-seals a rotated GCK to each of these.
291 #[derive(Serialize, utoipa::ToSchema)]
292 pub(crate) struct GroupMemberPubkey {
293 #[schema(value_type = String)]
294 user_id: UserId,
295 pubkey: String,
296 }
297
298 #[derive(Serialize, utoipa::ToSchema)]
299 pub(crate) struct GroupMemberResponse {
300 #[schema(value_type = String)]
301 user_id: UserId,
302 /// The member's account email, so an admin panel can identify them.
303 email: String,
304 role: String,
305 #[schema(value_type = String)]
306 added_at: DateTime<Utc>,
307 }
308
309 #[derive(Serialize, utoipa::ToSchema)]
310 pub(crate) struct GroupGrantResponse {
311 /// The caller's sealed GCK grant (base64); opened client-side with the
312 /// member's identity private key.
313 sealed_gck: String,
314 /// The GCK generation this grant was sealed under.
315 gck_version: i32,
316 }
317
318 /// Status of the authenticated user's subscription to this app's cloud sync.
319 /// Shape matches `synckit_client::SubscriptionStatus`.
320 #[derive(Serialize, utoipa::ToSchema)]
321 pub(crate) struct SyncSubscriptionStatusResponse {
322 pub active: bool,
323 /// Billing interval ("monthly" / "annual"). Kept under the legacy `tier`
324 /// key for client SDK backwards compatibility.
325 pub tier: Option<String>,
326 pub status: Option<String>,
327 pub storage_limit_bytes: Option<i64>,
328 /// Queued storage cap, applied at the next billing cycle. `None` when no
329 /// change is pending.
330 pub pending_storage_limit_bytes: Option<i64>,
331 pub storage_used_bytes: Option<i64>,
332 pub current_period_end: Option<String>,
333 }
334
335 /// Request body for `POST /api/v1/sync/app/pricing`. Identifies the app by
336 /// its public API key; no JWT required so the UI can quote pricing pre-login.
337 #[derive(Deserialize, utoipa::ToSchema)]
338 pub(crate) struct AppPricingRequest {
339 pub api_key: String,
340 }
341
342 /// Pricing formula constants the client uses to quote a price locally as the
343 /// user drags a cap slider. The same formula is enforced server-side at
344 /// checkout, clients are not trusted to compute the final price.
345 #[derive(Serialize, utoipa::ToSchema)]
346 pub(crate) struct AppPricingResponse {
347 pub app_name: String,
348 /// Floor charge in cents (monthly or annual, same floor applies to both).
349 pub min_charge_cents: i64,
350 /// Per-GiB monthly storage rate, in tenths of a cent.
351 pub per_gb_tenths_of_cent_per_month: i64,
352 /// Annual is monthly × this value.
353 pub annual_multiplier: i64,
354 pub min_cap_bytes: i64,
355 pub max_cap_bytes: i64,
356 }
357
358 /// Request body for `POST /api/v1/sync/subscription/quote`.
359 #[derive(Deserialize, utoipa::ToSchema)]
360 pub(crate) struct SyncQuoteRequest {
361 pub cap_bytes: i64,
362 pub interval: String,
363 }
364
365 #[derive(Serialize, utoipa::ToSchema)]
366 pub(crate) struct SyncQuoteResponse {
367 pub cap_bytes: i64,
368 pub interval: String,
369 pub price_cents: i64,
370 }
371
372 /// Request body for `POST /api/v1/sync/subscription/checkout`.
373 #[derive(Deserialize, utoipa::ToSchema)]
374 pub(crate) struct SyncSubscribeRequest {
375 pub cap_bytes: i64,
376 /// "monthly" or "annual".
377 pub interval: String,
378 }
379
380 #[derive(Serialize, utoipa::ToSchema)]
381 pub(crate) struct SyncCheckoutResponse {
382 pub checkout_url: String,
383 }
384
385 /// Request body for `POST /api/v1/sync/subscription/storage-cap`, queues a
386 /// cap change that applies at the next billing cycle.
387 #[derive(Deserialize, utoipa::ToSchema)]
388 pub(crate) struct SyncCapChangeRequest {
389 pub cap_bytes: i64,
390 }
391
392 #[derive(Deserialize, utoipa::ToSchema)]
393 pub(crate) struct PutKeyRequest {
394 pub encrypted_key: String,
395 /// Expected key version for optimistic concurrency control.
396 /// Server rejects with 409 Conflict if the current version doesn't match.
397 pub expected_version: i32,
398 }
399
400 #[derive(Serialize, utoipa::ToSchema)]
401 pub(crate) struct GetKeyResponse {
402 encrypted_key: String,
403 key_version: i32,
404 /// Current active key identifier.
405 key_id: i32,
406 /// If a rotation is in progress, the new key envelope and its key_id.
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pending_key: Option<PendingKeyInfo>,
409 }
410
411 #[derive(Serialize, utoipa::ToSchema)]
412 pub(crate) struct PendingKeyInfo {
413 encrypted_key: String,
414 key_id: i32,
415 }
416
417 // ── Key Rotation types ──
418
419 #[derive(Deserialize, utoipa::ToSchema)]
420 pub(crate) struct BeginRotationRequest {
421 #[schema(value_type = String)]
422 pub device_id: SyncDeviceId,
423 pub new_encrypted_key: String,
424 pub expected_key_version: i32,
425 }
426
427 #[derive(Serialize, utoipa::ToSchema)]
428 pub(crate) struct BeginRotationResponse {
429 rotation_id: uuid::Uuid,
430 target_seq: i64,
431 new_key_id: i32,
432 }
433
434 #[derive(Deserialize, utoipa::ToSchema)]
435 pub(crate) struct RotationEntriesRequest {
436 pub rotation_id: uuid::Uuid,
437 pub after_seq: i64,
438 }
439
440 #[derive(Serialize, utoipa::ToSchema)]
441 pub(crate) struct RotationEntriesResponse {
442 entries: Vec<RotationEntry>,
443 has_more: bool,
444 }
445
446 #[derive(Serialize, utoipa::ToSchema)]
447 pub(crate) struct RotationEntry {
448 seq: i64,
449 /// Source table and row id, echoed so the client can recompute the entry's
450 /// AEAD associated data when re-encrypting under the new key.
451 table: String,
452 row_id: String,
453 data: Option<serde_json::Value>,
454 }
455
456 #[derive(Deserialize, utoipa::ToSchema)]
457 pub(crate) struct RotationBatchRequest {
458 pub rotation_id: uuid::Uuid,
459 pub entries: Vec<RotationBatchEntry>,
460 }
461
462 #[derive(Deserialize, utoipa::ToSchema)]
463 pub(crate) struct RotationBatchEntry {
464 pub seq: i64,
465 pub data: Option<serde_json::Value>,
466 }
467
468 #[derive(Serialize, utoipa::ToSchema)]
469 pub(crate) struct RotationBatchResponse {
470 updated_count: u64,
471 }
472
473 #[derive(Deserialize, utoipa::ToSchema)]
474 pub(crate) struct CompleteRotationRequest {
475 pub rotation_id: uuid::Uuid,
476 }
477
478 #[derive(Serialize, utoipa::ToSchema)]
479 pub(crate) struct CompleteRotationErrorResponse {
480 remaining: i64,
481 }
482
483 #[derive(Deserialize, utoipa::ToSchema)]
484 pub(crate) struct BlobUploadUrlRequest {
485 pub hash: String,
486 pub size_bytes: i64,
487 }
488
489 #[derive(Serialize, utoipa::ToSchema)]
490 pub(crate) struct BlobUploadUrlResponse {
491 upload_url: String,
492 already_exists: bool,
493 }
494
495 #[derive(Deserialize, utoipa::ToSchema)]
496 pub(crate) struct BlobConfirmRequest {
497 pub hash: String,
498 // The confirm handler reads the authoritative object size from S3 and does
499 // not trust a client-declared size. Clients may still send `size_bytes`; it
500 // is ignored by deserialization (no `deny_unknown_fields`).
501 }
502
503 /// Open a multipart blob session. `size_bytes` is the *ciphertext* length,
504 /// which the client knows before sealing anything (`blob_encrypted_len`), so
505 /// both sides derive the same part geometry from it without a round trip.
506 #[derive(Deserialize, utoipa::ToSchema)]
507 pub(crate) struct BlobMultipartStartRequest {
508 pub hash: String,
509 pub size_bytes: i64,
510 }
511
512 #[derive(Serialize, utoipa::ToSchema)]
513 pub(crate) struct BlobMultipartStartResponse {
514 upload_id: String,
515 part_size: usize,
516 part_count: u32,
517 /// Same dedup short-circuit as the one-shot upload: when true no session
518 /// was opened and the other fields are empty.
519 already_exists: bool,
520 }
521
522 #[derive(Deserialize, utoipa::ToSchema)]
523 pub(crate) struct BlobMultipartPartsRequest {
524 pub hash: String,
525 pub upload_id: String,
526 /// Must match the `size_bytes` passed to `start`, the plan is deterministic
527 /// in it, and a different value would sign the wrong `Content-Length`s.
528 pub size_bytes: i64,
529 pub first_part: u32,
530 pub count: u32,
531 /// SHA-256 of each requested part's bytes, base64 of the raw digest,
532 /// positionally aligned with `first_part..first_part + count`. Bound into
533 /// the presigned URL so S3 rehashes the part and rejects a mismatch at write
534 /// time. Optional for now, since a client can only supply a checksum for a
535 /// part it has already built, which in practice means asking for one part
536 /// at a time. When present the length must equal `count`.
537 #[serde(default)]
538 pub checksums: Option<Vec<String>>,
539 }
540
541 #[derive(Serialize, utoipa::ToSchema)]
542 pub(crate) struct BlobMultipartPartUrl {
543 part_number: i32,
544 content_length: u64,
545 url: String,
546 }
547
548 #[derive(Serialize, utoipa::ToSchema)]
549 pub(crate) struct BlobMultipartPartsResponse {
550 parts: Vec<BlobMultipartPartUrl>,
551 expires_in: u64,
552 }
553
554 #[derive(Deserialize, utoipa::ToSchema)]
555 pub(crate) struct BlobMultipartCompletedPart {
556 pub part_number: i32,
557 pub etag: String,
558 }
559
560 #[derive(Deserialize, utoipa::ToSchema)]
561 pub(crate) struct BlobMultipartCompleteRequest {
562 pub hash: String,
563 pub upload_id: String,
564 pub parts: Vec<BlobMultipartCompletedPart>,
565 }
566
567 #[derive(Deserialize, utoipa::ToSchema)]
568 pub(crate) struct BlobMultipartAbortRequest {
569 pub hash: String,
570 pub upload_id: String,
571 }
572
573 #[derive(Deserialize, utoipa::ToSchema)]
574 pub(crate) struct BlobDownloadUrlRequest {
575 pub hash: String,
576 }
577
578 #[derive(Serialize, utoipa::ToSchema)]
579 pub(crate) struct BlobDownloadUrlResponse {
580 download_url: String,
581 }
582
583 // ── Developer billing types ──
584
585 /// Request body for `POST /api/sync/apps/{id}/billing/activate`. Knob shape
586 /// matches the columns after migration 118.
587 ///
588 /// In `enforcement_mode = "bulk"`: `storage_gb_cap` is required; `key_cap` and
589 /// `gb_per_key` must be omitted.
590 ///
591 /// In `enforcement_mode = "per_key"`: `key_cap` AND `gb_per_key` are required;
592 /// `storage_gb_cap` must be omitted.
593 #[derive(Deserialize)]
594 pub(crate) struct BillingActivateRequest {
595 pub enforcement_mode: String,
596 pub storage_gb_cap: Option<u32>,
597 pub key_cap: Option<u32>,
598 pub gb_per_key: Option<u32>,
599 }
600
601 /// Request body for `PATCH /api/sync/apps/{id}/billing`; same shape as
602 /// activate. (Reused via alias for clarity at call sites.)
603 pub(crate) type BillingPatchRequest = BillingActivateRequest;
604
605 /// Response from `POST /api/sync/apps/{id}/billing/setup`.
606 #[derive(Serialize)]
607 pub(crate) struct BillingSetupResponse {
608 pub stripe_customer_id: String,
609 pub billing_portal_url: String,
610 }
611
612 /// Response from `POST /api/sync/apps/{id}/billing/activate` and
613 /// `PATCH /api/sync/apps/{id}/billing`.
614 #[derive(Serialize)]
615 pub(crate) struct BillingUpdatedResponse {
616 pub monthly_price_cents: i64,
617 pub billing_status: String,
618 pub stripe_subscription_id: Option<String>,
619 }
620
621 /// Response from `GET /api/sync/apps/{id}/billing`.
622 #[derive(Serialize)]
623 pub(crate) struct BillingStatusResponse {
624 pub app_id: SyncAppId,
625 pub billing_status: String,
626 pub is_internal: bool,
627 pub enforcement_mode: String,
628 pub storage_gb_cap: Option<u32>,
629 pub key_cap: Option<u32>,
630 pub gb_per_key: Option<u32>,
631 pub bytes_stored: i64,
632 /// Egress in the current billing period. Tracked for developer-facing
633 /// stats only; egress is NOT a price input and NOT enforced as a cap.
634 pub bytes_egress_period: i64,
635 pub keys_claimed: u32,
636 pub last_warning_pct: u8,
637 pub current_period_start: Option<DateTime<Utc>>,
638 pub current_period_end: Option<DateTime<Utc>>,
639 /// Monthly price as computed by `synckit_billing::monthly_price_cents`.
640 /// `None` while in draft (knobs not yet set).
641 pub monthly_price_cents: Option<i64>,
642 }
643
644 // ── Key claim types ──
645
646 /// Request body for `POST /api/sync/keys/claim`. Server-to-server: developer's
647 /// backend sends the app's keys-endpoint secret alongside the SDK key being
648 /// claimed.
649 ///
650 /// `app_secret`, not `api_key`: the api_key is compiled into shipped clients
651 /// and so cannot gate an endpoint that spends the app's key cap.
652 #[derive(Deserialize)]
653 pub(crate) struct ClaimKeyRequest {
654 pub app_secret: String,
655 pub key: String,
656 }
657
658 /// Response body for `POST /api/sync/keys/claim`.
659 #[derive(Serialize)]
660 pub(crate) struct ClaimKeyResponse {
661 pub newly_claimed: bool,
662 pub total_claimed: i32,
663 }
664
665 /// Request body for `POST /api/sync/keys/release`.
666 #[derive(Deserialize)]
667 pub(crate) struct ReleaseKeyRequest {
668 pub app_secret: String,
669 pub key: String,
670 }
671
672 /// Response body for `POST /api/sync/keys/release`.
673 #[derive(Serialize)]
674 pub(crate) struct ReleaseKeyResponse {
675 pub newly_released: bool,
676 pub total_claimed: i32,
677 }
678
679 /// Request body for `POST /api/sync/keys/list`. POST + body (not GET + query)
680 /// to keep the secret out of access logs.
681 #[derive(Deserialize)]
682 pub(crate) struct ListKeysRequest {
683 pub app_secret: String,
684 pub limit: Option<u32>,
685 pub offset: Option<u32>,
686 }
687
688 /// One row in the active-key list returned by `POST /api/sync/keys/list`.
689 #[derive(Serialize)]
690 pub(crate) struct KeyInfo {
691 pub id: uuid::Uuid,
692 pub key: String,
693 pub claimed_at: DateTime<Utc>,
694 /// Bytes stored under this key (rolling counter, reconciled weekly by
695 /// the drift job). `0` if no upload has confirmed yet for this key.
696 pub bytes_stored: i64,
697 }
698
699 /// Response body for `POST /api/sync/keys/list`.
700 #[derive(Serialize)]
701 pub(crate) struct ListKeysResponse {
702 pub keys: Vec<KeyInfo>,
703 }
704
705 /// Response for create/regenerate that includes the plaintext API key (shown only once).
706 #[derive(Serialize)]
707 pub(super) struct AppWithKey {
708 #[serde(flatten)]
709 pub app: db::DbSyncApp,
710 /// The plaintext API key. Only returned on create and regenerate; not stored.
711 pub api_key: String,
712 }
713
714 /// Response for generating/rotating the keys-endpoint secret. The plaintext is
715 /// returned once and never again, only its hash is stored.
716 #[derive(Serialize)]
717 pub(super) struct AppKeysSecret {
718 #[serde(flatten)]
719 pub app: db::DbSyncApp,
720 /// The plaintext secret. Keep it on a developer backend; putting it in a
721 /// shipped client reintroduces exactly the weakness it exists to close.
722 pub app_secret: String,
723 }
724
725 // ── Helper ──
726
727 pub(super) fn generate_api_key() -> String {
728 use rand::Rng;
729 let mut bytes = [0u8; constants::SYNCKIT_API_KEY_LENGTH];
730 rand::rng().fill_bytes(&mut bytes);
731 hex::encode(bytes)
732 }
733
734 /// Generate the keys-endpoint secret. Same shape and entropy as an api_key;
735 /// what differs is where it is allowed to live, a developer backend only,
736 /// never compiled into a shipped client.
737 pub(super) fn generate_app_secret() -> String {
738 generate_api_key()
739 }
740
741 // ── Router ──
742
743 /// Build the SyncKit route tree.
744 ///
745 /// Three route groups with different auth and rate-limiting strategies:
746 ///
747 /// - **Auth routes** (`/api/sync/auth`): Public, rate-limited per-second (IP)
748 /// to prevent credential stuffing.
749 /// - **Sync routes** (push, pull, status, devices, keys, blobs): JWT-based
750 /// auth via `SyncUser` extractor, dual rate-limited: per-IP (prevents single
751 /// client abuse) AND per-app (prevents one developer's app from starving
752 /// others). Per-app limits are higher since an app may have many users.
753 /// - **App management routes** (`/api/sync/apps/...`): Session-based auth
754 /// via `AuthUser` extractor (accessed from the MNW dashboard), no extra
755 /// rate limit beyond the global middleware.
756 ///
757 /// `synckit_jwt_secret` is threaded in (rather than read from a global) so the
758 /// per-app rate limiter's key extractor can verify token signatures; see
759 /// [`crate::rate_limit::SyncAppKeyExtractor`].
760 pub fn synckit_routes(synckit_jwt_secret: Option<std::sync::Arc<String>>) -> CsrfRouter<AppState> {
761 let auth_rate_limit = crate::helpers::rate_limiter_per_sec(
762 constants::SYNCKIT_AUTH_RATE_LIMIT_PER_SEC,
763 constants::SYNCKIT_AUTH_RATE_LIMIT_BURST,
764 );
765
766 let auth_routes = CsrfRouter::new()
767 .route(
768 "/api/sync/auth",
769 post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth),
770 )
771 .route(
772 "/api/v1/sync/auth",
773 post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth),
774 )
775 .route(
776 "/api/sync/validate-app",
777 post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app),
778 )
779 .route(
780 "/api/v1/sync/validate-app",
781 post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app),
782 )
783 // Server-to-server SDK key claim/release/list (app_secret in body, no JWT).
784 .route(
785 "/api/sync/keys/claim",
786 post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::claim),
787 )
788 .route(
789 "/api/v1/sync/keys/claim",
790 post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::claim),
791 )
792 .route(
793 "/api/sync/keys/release",
794 post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::release),
795 )
796 .route(
797 "/api/v1/sync/keys/release",
798 post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::release),
799 )
800 .route(
801 "/api/sync/keys/list",
802 post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::list),
803 )
804 .route(
805 "/api/v1/sync/keys/list",
806 post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::list),
807 )
808 .route(
809 "/api/sync/app/pricing",
810 post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing),
811 )
812 .route(
813 "/api/v1/sync/app/pricing",
814 post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing),
815 )
816 .route_layer(GovernorLayer::new(auth_rate_limit));
817
818 let sync_ip_rate_limit = crate::helpers::rate_limiter_ms(
819 constants::SYNCKIT_SYNC_RATE_LIMIT_MS,
820 constants::SYNCKIT_SYNC_RATE_LIMIT_BURST,
821 );
822 let sync_app_rate_limit = crate::helpers::synckit_app_rate_limiter_ms(
823 synckit_jwt_secret,
824 constants::SYNCKIT_APP_RATE_LIMIT_MS,
825 constants::SYNCKIT_APP_RATE_LIMIT_BURST,
826 );
827
828 let sync_routes = CsrfRouter::new()
829 .route(
830 "/api/sync/push",
831 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push),
832 )
833 .route(
834 "/api/v1/sync/push",
835 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push),
836 )
837 .route(
838 "/api/sync/pull",
839 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull),
840 )
841 .route(
842 "/api/v1/sync/pull",
843 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull),
844 )
845 // Group sync: shared changelogs. Membership/admin gating lives inside the
846 // handlers (SyncUser identifies the caller); same JWT auth + dual rate
847 // limit as personal sync. GET+POST on one path merge, as with devices.
848 .route(
849 "/api/sync/groups",
850 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_group),
851 )
852 .route(
853 "/api/v1/sync/groups",
854 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_group),
855 )
856 .route_get("/api/sync/groups", get(groups::list_groups))
857 .route_get("/api/v1/sync/groups", get(groups::list_groups))
858 .route(
859 "/api/sync/groups/{id}/members",
860 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::add_member),
861 )
862 .route(
863 "/api/v1/sync/groups/{id}/members",
864 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::add_member),
865 )
866 .route_get("/api/sync/groups/{id}/members", get(groups::list_members))
867 .route_get(
868 "/api/v1/sync/groups/{id}/members",
869 get(groups::list_members),
870 )
871 .route(
872 "/api/sync/groups/{id}/members/{user_id}",
873 delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member),
874 )
875 .route(
876 "/api/v1/sync/groups/{id}/members/{user_id}",
877 delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member),
878 )
879 .route_get("/api/sync/groups/{id}/grant", get(groups::get_grant))
880 .route_get("/api/v1/sync/groups/{id}/grant", get(groups::get_grant))
881 .route_get("/api/sync/groups/{id}/pubkeys", get(groups::list_pubkeys))
882 .route_get(
883 "/api/v1/sync/groups/{id}/pubkeys",
884 get(groups::list_pubkeys),
885 )
886 .route(
887 "/api/sync/groups/{id}/push",
888 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push),
889 )
890 .route(
891 "/api/v1/sync/groups/{id}/push",
892 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push),
893 )
894 .route(
895 "/api/sync/groups/{id}/pull",
896 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_pull),
897 )
898 .route(
899 "/api/v1/sync/groups/{id}/pull",
900 post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_pull),
901 )
902 .route_get("/api/sync/subscribe", get(subscribe::sync_subscribe))
903 .route_get("/api/v1/sync/subscribe", get(subscribe::sync_subscribe))
904 .route_get("/api/sync/status", get(sync::sync_status))
905 .route_get("/api/v1/sync/status", get(sync::sync_status))
906 .route_get("/api/sync/account", get(sync::sync_account))
907 .route_get("/api/v1/sync/account", get(sync::sync_account))
908 .route_get(
909 "/api/sync/subscription",
910 get(sync::sync_subscription_status),
911 )
912 .route_get(
913 "/api/v1/sync/subscription",
914 get(sync::sync_subscription_status),
915 )
916 .route(
917 "/api/sync/subscription/quote",
918 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price),
919 )
920 .route(
921 "/api/v1/sync/subscription/quote",
922 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price),
923 )
924 .route(
925 "/api/sync/subscription/checkout",
926 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout),
927 )
928 .route(
929 "/api/v1/sync/subscription/checkout",
930 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout),
931 )
932 .route(
933 "/api/sync/subscription/storage-cap",
934 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change),
935 )
936 .route(
937 "/api/v1/sync/subscription/storage-cap",
938 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change),
939 )
940 .route(
941 "/api/sync/devices",
942 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device),
943 )
944 .route(
945 "/api/v1/sync/devices",
946 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device),
947 )
948 .route_get("/api/sync/devices", get(sync::list_devices))
949 .route_get("/api/v1/sync/devices", get(sync::list_devices))
950 .route(
951 "/api/sync/devices/{id}",
952 delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device),
953 )
954 .route(
955 "/api/v1/sync/devices/{id}",
956 delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device),
957 )
958 .route(
959 "/api/sync/keys",
960 put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key),
961 )
962 .route(
963 "/api/v1/sync/keys",
964 put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key),
965 )
966 .route_get("/api/sync/keys", get(sync::get_sync_key))
967 .route_get("/api/v1/sync/keys", get(sync::get_sync_key))
968 .route(
969 "/api/sync/keys/rotate",
970 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation),
971 )
972 .route(
973 "/api/v1/sync/keys/rotate",
974 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation),
975 )
976 .route(
977 "/api/sync/keys/rotate",
978 delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation),
979 )
980 .route(
981 "/api/v1/sync/keys/rotate",
982 delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation),
983 )
984 .route(
985 "/api/sync/keys/rotate/entries",
986 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries),
987 )
988 .route(
989 "/api/v1/sync/keys/rotate/entries",
990 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries),
991 )
992 .route(
993 "/api/sync/keys/rotate/batch",
994 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch),
995 )
996 .route(
997 "/api/v1/sync/keys/rotate/batch",
998 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch),
999 )
1000 .route(
1001 "/api/sync/keys/rotate/complete",
1002 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation),
1003 )
1004 .route(
1005 "/api/v1/sync/keys/rotate/complete",
1006 post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation),
1007 )
1008 .route(
1009 "/api/sync/blobs/upload",
1010 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url),
1011 )
1012 .route(
1013 "/api/v1/sync/blobs/upload",
1014 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url),
1015 )
1016 .route(
1017 "/api/sync/blobs/multipart/start",
1018 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_start),
1019 )
1020 .route(
1021 "/api/v1/sync/blobs/multipart/start",
1022 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_start),
1023 )
1024 .route(
1025 "/api/sync/blobs/multipart/parts",
1026 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_parts),
1027 )
1028 .route(
1029 "/api/v1/sync/blobs/multipart/parts",
1030 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_parts),
1031 )
1032 .route(
1033 "/api/sync/blobs/multipart/complete",
1034 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_complete),
1035 )
1036 .route(
1037 "/api/v1/sync/blobs/multipart/complete",
1038 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_complete),
1039 )
1040 .route(
1041 "/api/sync/blobs/multipart/abort",
1042 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_abort),
1043 )
1044 .route(
1045 "/api/v1/sync/blobs/multipart/abort",
1046 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_abort),
1047 )
1048 .route(
1049 "/api/sync/blobs/confirm",
1050 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload),
1051 )
1052 .route(
1053 "/api/v1/sync/blobs/confirm",
1054 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload),
1055 )
1056 .route(
1057 "/api/sync/blobs/download",
1058 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url),
1059 )
1060 .route(
1061 "/api/v1/sync/blobs/download",
1062 post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url),
1063 )
1064 .route(
1065 "/api/sync/blobs/{hash}",
1066 delete_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_delete),
1067 )
1068 .route(
1069 "/api/v1/sync/blobs/{hash}",
1070 delete_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_delete),
1071 )
1072 // Per-app rate limit (inner layer runs first): prevents one developer's
1073 // app from starving other apps. Extracts app ID from JWT payload.
1074 .route_layer(GovernorLayer::new(sync_app_rate_limit))
1075 // Per-IP rate limit (outer layer): prevents a single client from
1076 // overwhelming the endpoint regardless of which app they claim.
1077 .route_layer(GovernorLayer::new(sync_ip_rate_limit));
1078
1079 // App management endpoints use session auth (no extra rate limit beyond global)
1080 let app_routes = CsrfRouter::new()
1081 .route("/api/sync/apps", post_csrf(apps::create_app))
1082 .route("/api/v1/sync/apps", post_csrf(apps::create_app))
1083 .route_get("/api/sync/apps", get(apps::list_apps))
1084 .route_get("/api/v1/sync/apps", get(apps::list_apps))
1085 .route(
1086 "/api/sync/apps/{id}/regenerate-key",
1087 post_csrf(apps::regenerate_app_key),
1088 )
1089 .route(
1090 "/api/v1/sync/apps/{id}/regenerate-key",
1091 post_csrf(apps::regenerate_app_key),
1092 )
1093 .route(
1094 "/api/sync/apps/{id}/keys-secret",
1095 post_csrf(apps::regenerate_app_keys_secret),
1096 )
1097 .route(
1098 "/api/v1/sync/apps/{id}/keys-secret",
1099 post_csrf(apps::regenerate_app_keys_secret),
1100 )
1101 .route("/api/sync/apps/{id}/link", put_csrf(apps::update_app_link))
1102 .route(
1103 "/api/v1/sync/apps/{id}/link",
1104 put_csrf(apps::update_app_link),
1105 )
1106 .route("/api/sync/apps/{id}/slug", put_csrf(apps::update_app_slug))
1107 .route(
1108 "/api/v1/sync/apps/{id}/slug",
1109 put_csrf(apps::update_app_slug),
1110 )
1111 .route("/api/sync/apps/{id}", delete_csrf(apps::delete_app))
1112 .route("/api/v1/sync/apps/{id}", delete_csrf(apps::delete_app))
1113 // Developer billing (session auth, dashboard-driven).
1114 .route(
1115 "/api/sync/apps/{id}/billing/setup",
1116 post_csrf(billing::setup),
1117 )
1118 .route(
1119 "/api/v1/sync/apps/{id}/billing/setup",
1120 post_csrf(billing::setup),
1121 )
1122 .route(
1123 "/api/sync/apps/{id}/billing/activate",
1124 post_csrf(billing::activate),
1125 )
1126 .route(
1127 "/api/v1/sync/apps/{id}/billing/activate",
1128 post_csrf(billing::activate),
1129 )
1130 .route("/api/sync/apps/{id}/billing", patch_csrf(billing::patch))
1131 .route("/api/v1/sync/apps/{id}/billing", patch_csrf(billing::patch))
1132 .route("/api/sync/apps/{id}/billing", delete_csrf(billing::cancel))
1133 .route(
1134 "/api/v1/sync/apps/{id}/billing",
1135 delete_csrf(billing::cancel),
1136 )
1137 .route_get("/api/sync/apps/{id}/billing", get(billing::get))
1138 .route_get("/api/v1/sync/apps/{id}/billing", get(billing::get))
1139 .route_get("/api/sync/apps/{id}/billing/portal", get(billing::portal))
1140 .route_get(
1141 "/api/v1/sync/apps/{id}/billing/portal",
1142 get(billing::portal),
1143 );
1144
1145 auth_routes.merge(sync_routes).merge(app_routes)
1146 }
1147
1148 #[cfg(test)]
1149 mod tests {
1150 use super::client_version;
1151 use axum::http::{HeaderMap, HeaderValue, header::USER_AGENT};
1152
1153 fn ua(value: &str) -> HeaderMap {
1154 let mut headers = HeaderMap::new();
1155 headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap());
1156 headers
1157 }
1158
1159 #[test]
1160 fn reads_the_sdk_version() {
1161 assert_eq!(
1162 client_version(&ua("synckit-client/0.6.0")).as_deref(),
1163 Some("0.6.0")
1164 );
1165 // Pre-release and build suffixes are real versions, keep them whole.
1166 assert_eq!(
1167 client_version(&ua("synckit-client/1.0.0-rc.2")).as_deref(),
1168 Some("1.0.0-rc.2")
1169 );
1170 // A consumer app appending its own product token must not break the read.
1171 assert_eq!(
1172 client_version(&ua("synckit-client/0.6.0 audiofiles/0.9.1")).as_deref(),
1173 Some("0.6.0")
1174 );
1175 }
1176
1177 #[test]
1178 fn ignores_anything_that_is_not_the_sdk() {
1179 assert_eq!(client_version(&HeaderMap::new()), None);
1180 assert_eq!(client_version(&ua("curl/8.5.0")), None);
1181 assert_eq!(client_version(&ua("Mozilla/5.0 (X11; Linux x86_64)")), None);
1182 // Right prefix, no version.
1183 assert_eq!(client_version(&ua("synckit-client/")), None);
1184 // Prefix match must be exact, not a substring of some other product.
1185 assert_eq!(client_version(&ua("evil-synckit-client/9.9.9")), None);
1186 }
1187
1188 #[test]
1189 fn rejects_junk_rather_than_storing_it() {
1190 // Over the column width: dropped, not truncated into a plausible-looking
1191 // version.
1192 let long = format!("synckit-client/{}", "9".repeat(64));
1193 assert_eq!(client_version(&ua(&long)), None);
1194 // A version has to start with a digit, so a free-text string cannot
1195 // smuggle itself into the readout.
1196 assert_eq!(client_version(&ua("synckit-client/not-a-version")), None);
1197 assert_eq!(client_version(&ua("synckit-client/../../etc/passwd")), None);
1198 // At the boundary it is kept.
1199 let at_max = format!("synckit-client/1{}", "0".repeat(31));
1200 assert!(client_version(&ua(&at_max)).is_some());
1201 }
1202 }
1203