Skip to main content

max / makenotwork

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