Skip to main content

max / makenotwork

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