//! The router: every synckit path, and the auth and rate-limit tier each one //! sits behind. use axum::routing::get; use tower_governor::GovernorLayer; use super::{apps, auth, billing, blobs, groups, keys, subscribe, sync}; use crate::{ AppState, constants, csrf::{ CsrfRouter, delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf, put_csrf_skip, }, }; /// Reason strings for synckit CSRF Skip routes. The auth_routes and /// sync_routes blocks use server-to-server or JWT bearer auth with no /// session cookie; CSRF doesn't apply. The app_routes block IS /// session-authed (dashboard-driven) so those use `post_csrf` etc. const SYNCKIT_API_KEY_SKIP: &str = "synckit server-to-server: api_key auth, no session"; const SYNCKIT_APP_SECRET_SKIP: &str = "synckit server-to-server: keys-endpoint app_secret auth, no session"; const SYNCKIT_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session"; /// Build the SyncKit route tree. /// /// Three route groups with different auth and rate-limiting strategies: /// /// - **Auth routes**: Public, rate-limited per-second (IP) to prevent /// credential stuffing. `/api/sync/auth` and `/api/sync/validate-app` present /// the app's api_key; the three server-to-server SDK key routes /// (`keys/claim`, `keys/release`, `keys/list`) and `app/pricing` present the /// app_secret in the body instead. Read the builder below for the current /// membership. /// - **Sync routes** (push, pull, status, devices, keys, blobs): JWT-based /// auth via `SyncUser` extractor, dual rate-limited: per-IP (prevents single /// client abuse) AND per-app (prevents one developer's app from starving /// others). Per-app limits are higher since an app may have many users. /// - **App management routes** (`/api/sync/apps/...`): Session-based auth /// via `AuthUser` extractor (accessed from the MNW dashboard), no extra /// rate limit beyond the global middleware. /// /// `synckit_jwt_secret` is threaded in (rather than read from a global) so the /// per-app rate limiter's key extractor can verify token signatures; see /// [`crate::rate_limit::SyncAppKeyExtractor`]. pub fn synckit_routes(synckit_jwt_secret: Option>) -> CsrfRouter { let auth_rate_limit = crate::helpers::rate_limiter_per_sec( constants::SYNCKIT_AUTH_RATE_LIMIT_PER_SEC, constants::SYNCKIT_AUTH_RATE_LIMIT_BURST, ); let auth_routes = CsrfRouter::new() .route( "/api/sync/auth", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth), ) .route( "/api/v1/sync/auth", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth), ) .route( "/api/sync/validate-app", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app), ) .route( "/api/v1/sync/validate-app", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app), ) // Server-to-server SDK key claim/release/list (app_secret in body, no JWT). .route( "/api/sync/keys/claim", post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::claim), ) .route( "/api/v1/sync/keys/claim", post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::claim), ) .route( "/api/sync/keys/release", post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::release), ) .route( "/api/v1/sync/keys/release", post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::release), ) .route( "/api/sync/keys/list", post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::list), ) .route( "/api/v1/sync/keys/list", post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::list), ) .route( "/api/sync/app/pricing", post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing), ) .route( "/api/v1/sync/app/pricing", post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing), ) .route_layer(GovernorLayer::new(auth_rate_limit)); let sync_ip_rate_limit = crate::helpers::rate_limiter_ms( constants::SYNCKIT_SYNC_RATE_LIMIT_MS, constants::SYNCKIT_SYNC_RATE_LIMIT_BURST, ); 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, ); let sync_routes = CsrfRouter::new() .route( "/api/sync/push", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push), ) .route( "/api/v1/sync/push", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push), ) .route( "/api/sync/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull), ) .route( "/api/v1/sync/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull), ) // Group sync: shared changelogs. Membership/admin gating lives inside the // handlers (SyncUser identifies the caller); same JWT auth + dual rate // limit as personal sync. GET+POST on one path merge, as with devices. .route( "/api/sync/groups", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_group), ) .route( "/api/v1/sync/groups", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_group), ) .route_get("/api/sync/groups", get(groups::list_groups)) .route_get("/api/v1/sync/groups", get(groups::list_groups)) .route( "/api/sync/groups/{id}/members", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::add_member), ) .route( "/api/v1/sync/groups/{id}/members", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::add_member), ) .route_get("/api/sync/groups/{id}/members", get(groups::list_members)) .route_get( "/api/v1/sync/groups/{id}/members", get(groups::list_members), ) .route( "/api/sync/groups/{id}/members/{user_id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member), ) .route( "/api/v1/sync/groups/{id}/members/{user_id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member), ) // Invitations. The two accept-side routes are not nested under the group: // the caller is not a member yet and cannot be asked for a group id they // have no access to, so the token names the group instead. .route( "/api/sync/groups/{id}/invitations", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_invitation), ) .route( "/api/v1/sync/groups/{id}/invitations", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_invitation), ) .route_get( "/api/sync/groups/{id}/invitations", get(groups::list_invitations), ) .route_get( "/api/v1/sync/groups/{id}/invitations", get(groups::list_invitations), ) .route( "/api/sync/groups/{id}/invitations/{invitation_id}/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::confirm_invitation), ) .route( "/api/v1/sync/groups/{id}/invitations/{invitation_id}/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::confirm_invitation), ) .route( "/api/sync/groups/{id}/invitations/{invitation_id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::revoke_invitation), ) .route( "/api/v1/sync/groups/{id}/invitations/{invitation_id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::revoke_invitation), ) .route_get( "/api/sync/invitations/{token}", get(groups::preview_invitation), ) .route_get( "/api/v1/sync/invitations/{token}", get(groups::preview_invitation), ) .route( "/api/sync/invitations/accept", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::accept_invitation), ) .route( "/api/v1/sync/invitations/accept", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::accept_invitation), ) .route_get("/api/sync/groups/{id}/grant", get(groups::get_grant)) .route_get("/api/v1/sync/groups/{id}/grant", get(groups::get_grant)) .route_get("/api/sync/groups/{id}/pubkeys", get(groups::list_pubkeys)) .route_get( "/api/v1/sync/groups/{id}/pubkeys", get(groups::list_pubkeys), ) .route( "/api/sync/groups/{id}/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::rotate_key), ) .route( "/api/v1/sync/groups/{id}/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::rotate_key), ) .route( "/api/sync/groups/{id}/push", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push), ) .route( "/api/v1/sync/groups/{id}/push", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push), ) .route( "/api/sync/groups/{id}/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_pull), ) .route( "/api/v1/sync/groups/{id}/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_pull), ) .route_get("/api/sync/subscribe", get(subscribe::sync_subscribe)) .route_get("/api/v1/sync/subscribe", get(subscribe::sync_subscribe)) .route_get("/api/sync/status", get(sync::sync_status)) .route_get("/api/v1/sync/status", get(sync::sync_status)) .route_get("/api/sync/account", get(sync::sync_account)) .route_get("/api/v1/sync/account", get(sync::sync_account)) .route_get( "/api/sync/subscription", get(sync::sync_subscription_status), ) .route_get( "/api/v1/sync/subscription", get(sync::sync_subscription_status), ) .route( "/api/sync/subscription/quote", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price), ) .route( "/api/v1/sync/subscription/quote", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price), ) .route( "/api/sync/subscription/checkout", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout), ) .route( "/api/v1/sync/subscription/checkout", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout), ) .route( "/api/sync/subscription/storage-cap", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change), ) .route( "/api/v1/sync/subscription/storage-cap", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change), ) .route( "/api/sync/devices", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device), ) .route( "/api/v1/sync/devices", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device), ) .route_get("/api/sync/devices", get(sync::list_devices)) .route_get("/api/v1/sync/devices", get(sync::list_devices)) .route( "/api/sync/devices/{id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device), ) .route( "/api/v1/sync/devices/{id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device), ) .route( "/api/sync/keys", put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key), ) .route( "/api/v1/sync/keys", put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key), ) .route_get("/api/sync/keys", get(sync::get_sync_key)) .route_get("/api/v1/sync/keys", get(sync::get_sync_key)) .route( "/api/sync/keys/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation), ) .route( "/api/v1/sync/keys/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation), ) .route( "/api/sync/keys/rotate", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation), ) .route( "/api/v1/sync/keys/rotate", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation), ) .route( "/api/sync/keys/rotate/entries", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries), ) .route( "/api/v1/sync/keys/rotate/entries", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries), ) .route( "/api/sync/keys/rotate/batch", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch), ) .route( "/api/v1/sync/keys/rotate/batch", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch), ) .route( "/api/sync/keys/rotate/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation), ) .route( "/api/v1/sync/keys/rotate/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation), ) .route( "/api/sync/blobs/upload", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url), ) .route( "/api/v1/sync/blobs/upload", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url), ) .route( "/api/sync/blobs/multipart/start", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_start), ) .route( "/api/v1/sync/blobs/multipart/start", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_start), ) .route( "/api/sync/blobs/multipart/parts", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_parts), ) .route( "/api/v1/sync/blobs/multipart/parts", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_parts), ) .route( "/api/sync/blobs/multipart/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_complete), ) .route( "/api/v1/sync/blobs/multipart/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_complete), ) .route( "/api/sync/blobs/multipart/abort", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_abort), ) .route( "/api/v1/sync/blobs/multipart/abort", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_abort), ) .route( "/api/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload), ) .route( "/api/v1/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload), ) .route( "/api/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url), ) .route( "/api/v1/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url), ) .route( "/api/sync/blobs/{hash}", delete_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_delete), ) .route( "/api/v1/sync/blobs/{hash}", delete_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_delete), ) // Per-app rate limit (inner layer runs first): prevents one developer's // app from starving other apps. Extracts app ID from JWT payload. .route_layer(GovernorLayer::new(sync_app_rate_limit)) // Per-IP rate limit (outer layer): prevents a single client from // overwhelming the endpoint regardless of which app they claim. .route_layer(GovernorLayer::new(sync_ip_rate_limit)); // App management endpoints use session auth (no extra rate limit beyond global) let app_routes = CsrfRouter::new() .route("/api/sync/apps", post_csrf(apps::create_app)) .route("/api/v1/sync/apps", post_csrf(apps::create_app)) .route_get("/api/sync/apps", get(apps::list_apps)) .route_get("/api/v1/sync/apps", get(apps::list_apps)) .route( "/api/sync/apps/{id}/regenerate-key", post_csrf(apps::regenerate_app_key), ) .route( "/api/v1/sync/apps/{id}/regenerate-key", post_csrf(apps::regenerate_app_key), ) .route( "/api/sync/apps/{id}/keys-secret", post_csrf(apps::regenerate_app_keys_secret), ) .route( "/api/v1/sync/apps/{id}/keys-secret", post_csrf(apps::regenerate_app_keys_secret), ) .route("/api/sync/apps/{id}/link", put_csrf(apps::update_app_link)) .route( "/api/v1/sync/apps/{id}/link", put_csrf(apps::update_app_link), ) .route("/api/sync/apps/{id}/slug", put_csrf(apps::update_app_slug)) .route( "/api/v1/sync/apps/{id}/slug", put_csrf(apps::update_app_slug), ) .route("/api/sync/apps/{id}", delete_csrf(apps::delete_app)) .route("/api/v1/sync/apps/{id}", delete_csrf(apps::delete_app)) // Developer billing (session auth, dashboard-driven). .route( "/api/sync/apps/{id}/billing/setup", post_csrf(billing::setup), ) .route( "/api/v1/sync/apps/{id}/billing/setup", post_csrf(billing::setup), ) .route( "/api/sync/apps/{id}/billing/activate", post_csrf(billing::activate), ) .route( "/api/v1/sync/apps/{id}/billing/activate", post_csrf(billing::activate), ) .route("/api/sync/apps/{id}/billing", patch_csrf(billing::patch)) .route("/api/v1/sync/apps/{id}/billing", patch_csrf(billing::patch)) .route("/api/sync/apps/{id}/billing", delete_csrf(billing::cancel)) .route( "/api/v1/sync/apps/{id}/billing", delete_csrf(billing::cancel), ) .route_get("/api/sync/apps/{id}/billing", get(billing::get)) .route_get("/api/v1/sync/apps/{id}/billing", get(billing::get)) .route_get("/api/sync/apps/{id}/billing/portal", get(billing::portal)) .route_get( "/api/v1/sync/apps/{id}/billing/portal", get(billing::portal), ); auth_routes.merge(sync_routes).merge(app_routes) }