Skip to main content

max / makenotwork

24.4 KB · 647 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 keys;
22 mod subscribe;
23 pub(crate) mod sync;
24
25 use axum::routing::get;
26 use chrono::{DateTime, Utc};
27 use serde::{Deserialize, Serialize};
28 use tower_governor::GovernorLayer;
29
30 use crate::{
31 constants,
32 csrf::{delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf, put_csrf_skip, CsrfRouter},
33 db::{self, SyncAppId, SyncDeviceId, SyncOperation, SyncPlatform, UserId},
34 AppState,
35 };
36
37 /// Reason strings for synckit CSRF Skip routes. The auth_routes and
38 /// sync_routes blocks use server-to-server or JWT bearer auth with no
39 /// session cookie; CSRF doesn't apply. The app_routes block IS
40 /// session-authed (dashboard-driven) so those use `post_csrf` etc.
41 const SYNCKIT_API_KEY_SKIP: &str = "synckit server-to-server: api_key auth, no session";
42 const SYNCKIT_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session";
43
44 // ── Request/Response types ──
45
46 #[derive(Deserialize, utoipa::ToSchema)]
47 pub(crate) struct SyncAuthRequest {
48 pub email: String,
49 pub password: String,
50 pub api_key: String,
51 /// Developer-defined SDK key. Identifies which billing slot this session's
52 /// uploads count against. Required.
53 pub key: String,
54 }
55
56 #[derive(Serialize, utoipa::ToSchema)]
57 pub(crate) struct SyncAuthResponse {
58 token: String,
59 #[schema(value_type = String)]
60 user_id: UserId,
61 #[schema(value_type = String)]
62 app_id: SyncAppId,
63 }
64
65 #[derive(Deserialize, utoipa::ToSchema)]
66 pub(crate) struct ValidateAppQuery {
67 pub(crate) api_key: String,
68 }
69
70 #[derive(Serialize, utoipa::ToSchema)]
71 pub(crate) struct ValidateAppResponse {
72 app_name: String,
73 }
74
75 #[derive(Deserialize, utoipa::ToSchema)]
76 pub(crate) struct PushRequest {
77 #[schema(value_type = String)]
78 pub device_id: SyncDeviceId,
79 /// Client-generated UUID for idempotent push. If a push with the same
80 /// batch_id has already been committed, the server returns the existing
81 /// cursor without re-inserting.
82 pub batch_id: uuid::Uuid,
83 pub changes: Vec<ChangeEntry>,
84 }
85
86 #[derive(Deserialize, utoipa::ToSchema)]
87 pub(crate) struct ChangeEntry {
88 pub table: String,
89 #[schema(value_type = String)]
90 pub op: SyncOperation,
91 pub row_id: String,
92 #[schema(value_type = String)]
93 pub timestamp: DateTime<Utc>,
94 pub data: Option<serde_json::Value>,
95 }
96
97 #[derive(Serialize, utoipa::ToSchema)]
98 pub(crate) struct PushResponse {
99 cursor: i64,
100 }
101
102 #[derive(Deserialize, utoipa::ToSchema)]
103 pub(crate) struct PullRequest {
104 #[schema(value_type = String)]
105 pub device_id: SyncDeviceId,
106 pub cursor: i64,
107 /// Optional table name filter; only return entries for these tables.
108 #[serde(default)]
109 pub tables: Option<Vec<String>>,
110 /// Optional timestamp filter; only return entries at or after this time.
111 #[serde(default)]
112 #[schema(value_type = Option<String>)]
113 pub since: Option<DateTime<Utc>>,
114 }
115
116 #[derive(Serialize, utoipa::ToSchema)]
117 pub(crate) struct PullResponse {
118 changes: Vec<PullChangeEntry>,
119 cursor: i64,
120 has_more: bool,
121 }
122
123 #[derive(Serialize, utoipa::ToSchema)]
124 pub(crate) struct PullChangeEntry {
125 seq: i64,
126 #[schema(value_type = String)]
127 device_id: SyncDeviceId,
128 table: String,
129 op: String,
130 row_id: String,
131 #[schema(value_type = String)]
132 timestamp: DateTime<Utc>,
133 data: Option<serde_json::Value>,
134 /// Which encryption key was used. Null means key_id 1 (pre-rotation).
135 #[serde(skip_serializing_if = "Option::is_none")]
136 key_id: Option<i32>,
137 }
138
139 #[derive(Serialize, utoipa::ToSchema)]
140 pub(crate) struct SyncDeviceResponse {
141 #[schema(value_type = String)]
142 id: SyncDeviceId,
143 #[schema(value_type = String)]
144 app_id: SyncAppId,
145 #[schema(value_type = String)]
146 user_id: UserId,
147 device_name: String,
148 platform: String,
149 #[schema(value_type = String)]
150 last_seen_at: DateTime<Utc>,
151 #[schema(value_type = String)]
152 created_at: DateTime<Utc>,
153 }
154
155 #[derive(Deserialize, utoipa::ToSchema)]
156 pub(crate) struct RegisterDeviceRequest {
157 pub device_name: String,
158 #[schema(value_type = String)]
159 pub platform: SyncPlatform,
160 }
161
162 #[derive(Deserialize)]
163 pub struct CreateAppRequest {
164 pub name: String,
165 pub project_id: Option<String>,
166 pub item_id: Option<String>,
167 }
168
169 #[derive(Deserialize)]
170 pub struct UpdateAppLinkRequest {
171 pub project_id: Option<String>,
172 pub item_id: Option<String>,
173 }
174
175 #[derive(Deserialize)]
176 pub struct UpdateAppSlugRequest {
177 pub slug: String,
178 }
179
180 #[derive(Serialize, utoipa::ToSchema)]
181 pub(crate) struct SyncStatusResponse {
182 total_changes: i64,
183 latest_cursor: Option<i64>,
184 }
185
186 #[derive(Serialize, utoipa::ToSchema)]
187 pub(crate) struct SyncAccountResponse {
188 pub email: String,
189 pub username: String,
190 }
191
192 /// Status of the authenticated user's subscription to this app's cloud sync.
193 /// Shape matches `synckit_client::SubscriptionStatus`.
194 #[derive(Serialize, utoipa::ToSchema)]
195 pub(crate) struct SyncSubscriptionStatusResponse {
196 pub active: bool,
197 /// Billing interval ("monthly" / "annual"). Kept under the legacy `tier`
198 /// key for client SDK backwards compatibility.
199 pub tier: Option<String>,
200 pub status: Option<String>,
201 pub storage_limit_bytes: Option<i64>,
202 /// Queued storage cap, applied at the next billing cycle. `None` when no
203 /// change is pending.
204 pub pending_storage_limit_bytes: Option<i64>,
205 pub storage_used_bytes: Option<i64>,
206 pub current_period_end: Option<String>,
207 }
208
209 /// Request body for `POST /api/v1/sync/app/pricing`. Identifies the app by
210 /// its public API key; no JWT required so the UI can quote pricing pre-login.
211 #[derive(Deserialize, utoipa::ToSchema)]
212 pub(crate) struct AppPricingRequest {
213 pub api_key: String,
214 }
215
216 /// Pricing formula constants the client uses to quote a price locally as the
217 /// user drags a cap slider. The same formula is enforced server-side at
218 /// checkout — clients are not trusted to compute the final price.
219 #[derive(Serialize, utoipa::ToSchema)]
220 pub(crate) struct AppPricingResponse {
221 pub app_name: String,
222 /// Floor charge in cents (monthly or annual — same floor applies to both).
223 pub min_charge_cents: i64,
224 /// Per-GiB monthly storage rate, in tenths of a cent.
225 pub per_gb_tenths_of_cent_per_month: i64,
226 /// Annual is monthly × this value.
227 pub annual_multiplier: i64,
228 pub min_cap_bytes: i64,
229 pub max_cap_bytes: i64,
230 }
231
232 /// Request body for `POST /api/v1/sync/subscription/quote`.
233 #[derive(Deserialize, utoipa::ToSchema)]
234 pub(crate) struct SyncQuoteRequest {
235 pub cap_bytes: i64,
236 pub interval: String,
237 }
238
239 #[derive(Serialize, utoipa::ToSchema)]
240 pub(crate) struct SyncQuoteResponse {
241 pub cap_bytes: i64,
242 pub interval: String,
243 pub price_cents: i64,
244 }
245
246 /// Request body for `POST /api/v1/sync/subscription/checkout`.
247 #[derive(Deserialize, utoipa::ToSchema)]
248 pub(crate) struct SyncSubscribeRequest {
249 pub cap_bytes: i64,
250 /// "monthly" or "annual".
251 pub interval: String,
252 }
253
254 #[derive(Serialize, utoipa::ToSchema)]
255 pub(crate) struct SyncCheckoutResponse {
256 pub checkout_url: String,
257 }
258
259 /// Request body for `POST /api/v1/sync/subscription/storage-cap` — queues a
260 /// cap change that applies at the next billing cycle.
261 #[derive(Deserialize, utoipa::ToSchema)]
262 pub(crate) struct SyncCapChangeRequest {
263 pub cap_bytes: i64,
264 }
265
266 #[derive(Deserialize, utoipa::ToSchema)]
267 pub(crate) struct PutKeyRequest {
268 pub encrypted_key: String,
269 /// Expected key version for optimistic concurrency control.
270 /// Server rejects with 409 Conflict if the current version doesn't match.
271 pub expected_version: i32,
272 }
273
274 #[derive(Serialize, utoipa::ToSchema)]
275 pub(crate) struct GetKeyResponse {
276 encrypted_key: String,
277 key_version: i32,
278 /// Current active key identifier.
279 key_id: i32,
280 /// If a rotation is in progress, the new key envelope and its key_id.
281 #[serde(skip_serializing_if = "Option::is_none")]
282 pending_key: Option<PendingKeyInfo>,
283 }
284
285 #[derive(Serialize, utoipa::ToSchema)]
286 pub(crate) struct PendingKeyInfo {
287 encrypted_key: String,
288 key_id: i32,
289 }
290
291 // ── Key Rotation types ──
292
293 #[derive(Deserialize, utoipa::ToSchema)]
294 pub(crate) struct BeginRotationRequest {
295 #[schema(value_type = String)]
296 pub device_id: SyncDeviceId,
297 pub new_encrypted_key: String,
298 pub expected_key_version: i32,
299 }
300
301 #[derive(Serialize, utoipa::ToSchema)]
302 pub(crate) struct BeginRotationResponse {
303 rotation_id: uuid::Uuid,
304 target_seq: i64,
305 new_key_id: i32,
306 }
307
308 #[derive(Deserialize, utoipa::ToSchema)]
309 pub(crate) struct RotationEntriesRequest {
310 pub rotation_id: uuid::Uuid,
311 pub after_seq: i64,
312 }
313
314 #[derive(Serialize, utoipa::ToSchema)]
315 pub(crate) struct RotationEntriesResponse {
316 entries: Vec<RotationEntry>,
317 has_more: bool,
318 }
319
320 #[derive(Serialize, utoipa::ToSchema)]
321 pub(crate) struct RotationEntry {
322 seq: i64,
323 data: Option<serde_json::Value>,
324 }
325
326 #[derive(Deserialize, utoipa::ToSchema)]
327 pub(crate) struct RotationBatchRequest {
328 pub rotation_id: uuid::Uuid,
329 pub entries: Vec<RotationBatchEntry>,
330 }
331
332 #[derive(Deserialize, utoipa::ToSchema)]
333 pub(crate) struct RotationBatchEntry {
334 pub seq: i64,
335 pub data: Option<serde_json::Value>,
336 }
337
338 #[derive(Serialize, utoipa::ToSchema)]
339 pub(crate) struct RotationBatchResponse {
340 updated_count: u64,
341 }
342
343 #[derive(Deserialize, utoipa::ToSchema)]
344 pub(crate) struct CompleteRotationRequest {
345 pub rotation_id: uuid::Uuid,
346 }
347
348 #[derive(Serialize, utoipa::ToSchema)]
349 pub(crate) struct CompleteRotationErrorResponse {
350 remaining: i64,
351 }
352
353 #[derive(Deserialize, utoipa::ToSchema)]
354 pub(crate) struct BlobUploadUrlRequest {
355 pub hash: String,
356 pub size_bytes: i64,
357 }
358
359 #[derive(Serialize, utoipa::ToSchema)]
360 pub(crate) struct BlobUploadUrlResponse {
361 upload_url: String,
362 already_exists: bool,
363 }
364
365 #[derive(Deserialize, utoipa::ToSchema)]
366 pub(crate) struct BlobConfirmRequest {
367 pub hash: String,
368 pub size_bytes: i64,
369 }
370
371 #[derive(Deserialize, utoipa::ToSchema)]
372 pub(crate) struct BlobDownloadUrlRequest {
373 pub hash: String,
374 }
375
376 #[derive(Serialize, utoipa::ToSchema)]
377 pub(crate) struct BlobDownloadUrlResponse {
378 download_url: String,
379 }
380
381 // ── Developer billing types ──
382
383 /// Request body for `POST /api/sync/apps/{id}/billing/activate`. Knob shape
384 /// matches the columns after migration 118.
385 ///
386 /// In `enforcement_mode = "bulk"`: `storage_gb_cap` is required; `key_cap` and
387 /// `gb_per_key` must be omitted.
388 ///
389 /// In `enforcement_mode = "per_key"`: `key_cap` AND `gb_per_key` are required;
390 /// `storage_gb_cap` must be omitted.
391 #[derive(Deserialize)]
392 pub(crate) struct BillingActivateRequest {
393 pub enforcement_mode: String,
394 pub storage_gb_cap: Option<u32>,
395 pub key_cap: Option<u32>,
396 pub gb_per_key: Option<u32>,
397 }
398
399 /// Request body for `PATCH /api/sync/apps/{id}/billing`; same shape as
400 /// activate. (Reused via alias for clarity at call sites.)
401 pub(crate) type BillingPatchRequest = BillingActivateRequest;
402
403 /// Response from `POST /api/sync/apps/{id}/billing/setup`.
404 #[derive(Serialize)]
405 pub(crate) struct BillingSetupResponse {
406 pub stripe_customer_id: String,
407 pub billing_portal_url: String,
408 }
409
410 /// Response from `POST /api/sync/apps/{id}/billing/activate` and
411 /// `PATCH /api/sync/apps/{id}/billing`.
412 #[derive(Serialize)]
413 pub(crate) struct BillingUpdatedResponse {
414 pub monthly_price_cents: i64,
415 pub billing_status: String,
416 pub stripe_subscription_id: Option<String>,
417 }
418
419 /// Response from `GET /api/sync/apps/{id}/billing`.
420 #[derive(Serialize)]
421 pub(crate) struct BillingStatusResponse {
422 pub app_id: SyncAppId,
423 pub billing_status: String,
424 pub is_internal: bool,
425 pub enforcement_mode: String,
426 pub storage_gb_cap: Option<u32>,
427 pub key_cap: Option<u32>,
428 pub gb_per_key: Option<u32>,
429 pub bytes_stored: i64,
430 /// Egress in the current billing period. Tracked for developer-facing
431 /// stats only; egress is NOT a price input and NOT enforced as a cap.
432 pub bytes_egress_period: i64,
433 pub keys_claimed: u32,
434 pub last_warning_pct: u8,
435 pub current_period_start: Option<DateTime<Utc>>,
436 pub current_period_end: Option<DateTime<Utc>>,
437 /// Monthly price as computed by `synckit_billing::monthly_price_cents`.
438 /// `None` while in draft (knobs not yet set).
439 pub monthly_price_cents: Option<i64>,
440 }
441
442 // ── Key claim types ──
443
444 /// Request body for `POST /api/sync/keys/claim`. Server-to-server: developer's
445 /// backend sends the SyncKit app's `api_key` alongside the SDK key being
446 /// claimed.
447 #[derive(Deserialize)]
448 pub(crate) struct ClaimKeyRequest {
449 pub api_key: String,
450 pub key: String,
451 }
452
453 /// Response body for `POST /api/sync/keys/claim`.
454 #[derive(Serialize)]
455 pub(crate) struct ClaimKeyResponse {
456 pub newly_claimed: bool,
457 pub total_claimed: i32,
458 }
459
460 /// Request body for `POST /api/sync/keys/release`.
461 #[derive(Deserialize)]
462 pub(crate) struct ReleaseKeyRequest {
463 pub api_key: String,
464 pub key: String,
465 }
466
467 /// Response body for `POST /api/sync/keys/release`.
468 #[derive(Serialize)]
469 pub(crate) struct ReleaseKeyResponse {
470 pub newly_released: bool,
471 pub total_claimed: i32,
472 }
473
474 /// Request body for `POST /api/sync/keys/list`. POST + body (not GET + query)
475 /// to keep the api_key out of access logs.
476 #[derive(Deserialize)]
477 pub(crate) struct ListKeysRequest {
478 pub api_key: String,
479 pub limit: Option<u32>,
480 pub offset: Option<u32>,
481 }
482
483 /// One row in the active-key list returned by `POST /api/sync/keys/list`.
484 #[derive(Serialize)]
485 pub(crate) struct KeyInfo {
486 pub id: uuid::Uuid,
487 pub key: String,
488 pub claimed_at: DateTime<Utc>,
489 /// Bytes stored under this key (rolling counter, reconciled weekly by
490 /// the drift job). `0` if no upload has confirmed yet for this key.
491 pub bytes_stored: i64,
492 }
493
494 /// Response body for `POST /api/sync/keys/list`.
495 #[derive(Serialize)]
496 pub(crate) struct ListKeysResponse {
497 pub keys: Vec<KeyInfo>,
498 }
499
500 /// Response for create/regenerate that includes the plaintext API key (shown only once).
501 #[derive(Serialize)]
502 pub(super) struct AppWithKey {
503 #[serde(flatten)]
504 pub app: db::DbSyncApp,
505 /// The plaintext API key. Only returned on create and regenerate; not stored.
506 pub api_key: String,
507 }
508
509 // ── Helper ──
510
511 pub(super) fn generate_api_key() -> String {
512 use rand::RngCore;
513 let mut bytes = [0u8; constants::SYNCKIT_API_KEY_LENGTH];
514 rand::rng().fill_bytes(&mut bytes);
515 hex::encode(bytes)
516 }
517
518 // ── Router ──
519
520 /// Build the SyncKit route tree.
521 ///
522 /// Three route groups with different auth and rate-limiting strategies:
523 ///
524 /// - **Auth routes** (`/api/sync/auth`): Public, rate-limited per-second (IP)
525 /// to prevent credential stuffing.
526 /// - **Sync routes** (push, pull, status, devices, keys, blobs): JWT-based
527 /// auth via `SyncUser` extractor, dual rate-limited: per-IP (prevents single
528 /// client abuse) AND per-app (prevents one developer's app from starving
529 /// others). Per-app limits are higher since an app may have many users.
530 /// - **App management routes** (`/api/sync/apps/...`): Session-based auth
531 /// via `AuthUser` extractor (accessed from the MNW dashboard), no extra
532 /// rate limit beyond the global middleware.
533 ///
534 /// `synckit_jwt_secret` is threaded in (rather than read from a global) so the
535 /// per-app rate limiter's key extractor can verify token signatures; see
536 /// [`crate::rate_limit::SyncAppKeyExtractor`].
537 pub fn synckit_routes(synckit_jwt_secret: Option<std::sync::Arc<String>>) -> CsrfRouter<AppState> {
538 let auth_rate_limit = crate::helpers::rate_limiter_per_sec(constants::SYNCKIT_AUTH_RATE_LIMIT_PER_SEC, constants::SYNCKIT_AUTH_RATE_LIMIT_BURST);
539
540 let auth_routes = CsrfRouter::new()
541 .route("/api/sync/auth", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth))
542 .route("/api/v1/sync/auth", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth))
543 .route("/api/sync/validate-app", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app))
544 .route("/api/v1/sync/validate-app", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app))
545 // Server-to-server SDK key claim/release/list (api_key in body, no JWT).
546 .route("/api/sync/keys/claim", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::claim))
547 .route("/api/v1/sync/keys/claim", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::claim))
548 .route("/api/sync/keys/release", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::release))
549 .route("/api/v1/sync/keys/release", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::release))
550 .route("/api/sync/keys/list", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::list))
551 .route("/api/v1/sync/keys/list", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::list))
552 .route("/api/sync/app/pricing", post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing))
553 .route("/api/v1/sync/app/pricing", post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing))
554 .route_layer(GovernorLayer {
555 config: auth_rate_limit,
556 });
557
558 let sync_ip_rate_limit = crate::helpers::rate_limiter_ms(constants::SYNCKIT_SYNC_RATE_LIMIT_MS, constants::SYNCKIT_SYNC_RATE_LIMIT_BURST);
559 let sync_app_rate_limit = crate::helpers::synckit_app_rate_limiter_ms(synckit_jwt_secret, constants::SYNCKIT_APP_RATE_LIMIT_MS, constants::SYNCKIT_APP_RATE_LIMIT_BURST);
560
561 let sync_routes = CsrfRouter::new()
562 .route("/api/sync/push", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push))
563 .route("/api/v1/sync/push", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push))
564 .route("/api/sync/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull))
565 .route("/api/v1/sync/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull))
566 .route_get("/api/sync/subscribe", get(subscribe::sync_subscribe))
567 .route_get("/api/v1/sync/subscribe", get(subscribe::sync_subscribe))
568 .route_get("/api/sync/status", get(sync::sync_status))
569 .route_get("/api/v1/sync/status", get(sync::sync_status))
570 .route_get("/api/sync/account", get(sync::sync_account))
571 .route_get("/api/v1/sync/account", get(sync::sync_account))
572 .route_get("/api/sync/subscription", get(sync::sync_subscription_status))
573 .route_get("/api/v1/sync/subscription", get(sync::sync_subscription_status))
574 .route("/api/sync/subscription/quote", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price))
575 .route("/api/v1/sync/subscription/quote", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price))
576 .route("/api/sync/subscription/checkout", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout))
577 .route("/api/v1/sync/subscription/checkout", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout))
578 .route("/api/sync/subscription/storage-cap", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change))
579 .route("/api/v1/sync/subscription/storage-cap", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change))
580 .route("/api/sync/devices", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device))
581 .route("/api/v1/sync/devices", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device))
582 .route_get("/api/sync/devices", get(sync::list_devices))
583 .route_get("/api/v1/sync/devices", get(sync::list_devices))
584 .route("/api/sync/devices/{id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device))
585 .route("/api/v1/sync/devices/{id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device))
586 .route("/api/sync/keys", put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key))
587 .route("/api/v1/sync/keys", put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key))
588 .route_get("/api/sync/keys", get(sync::get_sync_key))
589 .route_get("/api/v1/sync/keys", get(sync::get_sync_key))
590 .route("/api/sync/keys/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation))
591 .route("/api/v1/sync/keys/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation))
592 .route("/api/sync/keys/rotate", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation))
593 .route("/api/v1/sync/keys/rotate", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation))
594 .route("/api/sync/keys/rotate/entries", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries))
595 .route("/api/v1/sync/keys/rotate/entries", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries))
596 .route("/api/sync/keys/rotate/batch", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch))
597 .route("/api/v1/sync/keys/rotate/batch", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch))
598 .route("/api/sync/keys/rotate/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation))
599 .route("/api/v1/sync/keys/rotate/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation))
600 .route("/api/sync/blobs/upload", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url))
601 .route("/api/v1/sync/blobs/upload", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url))
602 .route("/api/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload))
603 .route("/api/v1/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload))
604 .route("/api/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url))
605 .route("/api/v1/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url))
606 // Per-app rate limit (inner layer runs first): prevents one developer's
607 // app from starving other apps. Extracts app ID from JWT payload.
608 .route_layer(GovernorLayer {
609 config: sync_app_rate_limit,
610 })
611 // Per-IP rate limit (outer layer): prevents a single client from
612 // overwhelming the endpoint regardless of which app they claim.
613 .route_layer(GovernorLayer {
614 config: sync_ip_rate_limit,
615 });
616
617 // App management endpoints use session auth (no extra rate limit beyond global)
618 let app_routes = CsrfRouter::new()
619 .route("/api/sync/apps", post_csrf(apps::create_app))
620 .route("/api/v1/sync/apps", post_csrf(apps::create_app))
621 .route_get("/api/sync/apps", get(apps::list_apps))
622 .route_get("/api/v1/sync/apps", get(apps::list_apps))
623 .route("/api/sync/apps/{id}/regenerate-key", post_csrf(apps::regenerate_app_key))
624 .route("/api/v1/sync/apps/{id}/regenerate-key", post_csrf(apps::regenerate_app_key))
625 .route("/api/sync/apps/{id}/link", put_csrf(apps::update_app_link))
626 .route("/api/v1/sync/apps/{id}/link", put_csrf(apps::update_app_link))
627 .route("/api/sync/apps/{id}/slug", put_csrf(apps::update_app_slug))
628 .route("/api/v1/sync/apps/{id}/slug", put_csrf(apps::update_app_slug))
629 .route("/api/sync/apps/{id}", delete_csrf(apps::delete_app))
630 .route("/api/v1/sync/apps/{id}", delete_csrf(apps::delete_app))
631 // Developer billing (session auth, dashboard-driven).
632 .route("/api/sync/apps/{id}/billing/setup", post_csrf(billing::setup))
633 .route("/api/v1/sync/apps/{id}/billing/setup", post_csrf(billing::setup))
634 .route("/api/sync/apps/{id}/billing/activate", post_csrf(billing::activate))
635 .route("/api/v1/sync/apps/{id}/billing/activate", post_csrf(billing::activate))
636 .route("/api/sync/apps/{id}/billing", patch_csrf(billing::patch))
637 .route("/api/v1/sync/apps/{id}/billing", patch_csrf(billing::patch))
638 .route("/api/sync/apps/{id}/billing", delete_csrf(billing::cancel))
639 .route("/api/v1/sync/apps/{id}/billing", delete_csrf(billing::cancel))
640 .route_get("/api/sync/apps/{id}/billing", get(billing::get))
641 .route_get("/api/v1/sync/apps/{id}/billing", get(billing::get))
642 .route_get("/api/sync/apps/{id}/billing/portal", get(billing::portal))
643 .route_get("/api/v1/sync/apps/{id}/billing/portal", get(billing::portal));
644
645 auth_routes.merge(sync_routes).merge(app_routes)
646 }
647