//! SyncKit cloud sync API. //! //! Provides push/pull changelog sync, device management, E2E encryption key //! storage, and blob storage endpoints for the SyncKit client SDK. All sync //! and device endpoints use JWT-based authentication (issued by the //! `/api/sync/auth` endpoint), which is separate from the session-based auth //! used by the rest of the MNW web application. App management endpoints //! (create, list, delete apps) use the standard session auth since they are //! accessed from the MNW dashboard. //! //! Rate limiting is applied in two tiers: a stricter per-second limit on the //! auth endpoint (to prevent credential stuffing) and a per-millisecond limit //! on all other sync/device/key/blob endpoints. //! //! See also: `/docs/developer/synckit` pub(crate) mod apps; pub(crate) mod auth; pub(crate) mod billing; pub(crate) mod blobs; pub(crate) mod keys; mod subscribe; pub(crate) mod sync; use axum::routing::get; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tower_governor::GovernorLayer; use crate::{ constants, csrf::{delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf, put_csrf_skip, CsrfRouter}, db::{self, SyncAppId, SyncDeviceId, SyncOperation, SyncPlatform, UserId}, AppState, }; /// 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_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session"; // ── Request/Response types ── #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct SyncAuthRequest { pub email: String, pub password: String, pub api_key: String, /// Developer-defined SDK key. Identifies which billing slot this session's /// uploads count against. Required. pub key: String, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct SyncAuthResponse { token: String, #[schema(value_type = String)] user_id: UserId, #[schema(value_type = String)] app_id: SyncAppId, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct ValidateAppQuery { pub(crate) api_key: String, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct ValidateAppResponse { app_name: String, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct PushRequest { #[schema(value_type = String)] pub device_id: SyncDeviceId, /// Client-generated UUID for idempotent push. If a push with the same /// batch_id has already been committed, the server returns the existing /// cursor without re-inserting. pub batch_id: uuid::Uuid, pub changes: Vec, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct ChangeEntry { pub table: String, #[schema(value_type = String)] pub op: SyncOperation, pub row_id: String, #[schema(value_type = String)] pub timestamp: DateTime, pub data: Option, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct PushResponse { cursor: i64, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct PullRequest { #[schema(value_type = String)] pub device_id: SyncDeviceId, pub cursor: i64, /// Optional table name filter; only return entries for these tables. #[serde(default)] pub tables: Option>, /// Optional timestamp filter; only return entries at or after this time. #[serde(default)] #[schema(value_type = Option)] pub since: Option>, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct PullResponse { changes: Vec, cursor: i64, has_more: bool, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct PullChangeEntry { seq: i64, #[schema(value_type = String)] device_id: SyncDeviceId, table: String, op: String, row_id: String, #[schema(value_type = String)] timestamp: DateTime, data: Option, /// Which encryption key was used. Null means key_id 1 (pre-rotation). #[serde(skip_serializing_if = "Option::is_none")] key_id: Option, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct SyncDeviceResponse { #[schema(value_type = String)] id: SyncDeviceId, #[schema(value_type = String)] app_id: SyncAppId, #[schema(value_type = String)] user_id: UserId, device_name: String, platform: String, #[schema(value_type = String)] last_seen_at: DateTime, #[schema(value_type = String)] created_at: DateTime, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct RegisterDeviceRequest { pub device_name: String, #[schema(value_type = String)] pub platform: SyncPlatform, } #[derive(Deserialize)] pub struct CreateAppRequest { pub name: String, pub project_id: Option, pub item_id: Option, } #[derive(Deserialize)] pub struct UpdateAppLinkRequest { pub project_id: Option, pub item_id: Option, } #[derive(Deserialize)] pub struct UpdateAppSlugRequest { pub slug: String, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct SyncStatusResponse { total_changes: i64, latest_cursor: Option, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct SyncAccountResponse { pub email: String, pub username: String, } /// Status of the authenticated user's subscription to this app's cloud sync. /// Shape matches `synckit_client::SubscriptionStatus`. #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct SyncSubscriptionStatusResponse { pub active: bool, /// Billing interval ("monthly" / "annual"). Kept under the legacy `tier` /// key for client SDK backwards compatibility. pub tier: Option, pub status: Option, pub storage_limit_bytes: Option, /// Queued storage cap, applied at the next billing cycle. `None` when no /// change is pending. pub pending_storage_limit_bytes: Option, pub storage_used_bytes: Option, pub current_period_end: Option, } /// Request body for `POST /api/v1/sync/app/pricing`. Identifies the app by /// its public API key; no JWT required so the UI can quote pricing pre-login. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct AppPricingRequest { pub api_key: String, } /// Pricing formula constants the client uses to quote a price locally as the /// user drags a cap slider. The same formula is enforced server-side at /// checkout — clients are not trusted to compute the final price. #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct AppPricingResponse { pub app_name: String, /// Floor charge in cents (monthly or annual — same floor applies to both). pub min_charge_cents: i64, /// Per-GiB monthly storage rate, in tenths of a cent. pub per_gb_tenths_of_cent_per_month: i64, /// Annual is monthly × this value. pub annual_multiplier: i64, pub min_cap_bytes: i64, pub max_cap_bytes: i64, } /// Request body for `POST /api/v1/sync/subscription/quote`. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct SyncQuoteRequest { pub cap_bytes: i64, pub interval: String, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct SyncQuoteResponse { pub cap_bytes: i64, pub interval: String, pub price_cents: i64, } /// Request body for `POST /api/v1/sync/subscription/checkout`. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct SyncSubscribeRequest { pub cap_bytes: i64, /// "monthly" or "annual". pub interval: String, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct SyncCheckoutResponse { pub checkout_url: String, } /// Request body for `POST /api/v1/sync/subscription/storage-cap` — queues a /// cap change that applies at the next billing cycle. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct SyncCapChangeRequest { pub cap_bytes: i64, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct PutKeyRequest { pub encrypted_key: String, /// Expected key version for optimistic concurrency control. /// Server rejects with 409 Conflict if the current version doesn't match. pub expected_version: i32, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct GetKeyResponse { encrypted_key: String, key_version: i32, /// Current active key identifier. key_id: i32, /// If a rotation is in progress, the new key envelope and its key_id. #[serde(skip_serializing_if = "Option::is_none")] pending_key: Option, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct PendingKeyInfo { encrypted_key: String, key_id: i32, } // ── Key Rotation types ── #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BeginRotationRequest { #[schema(value_type = String)] pub device_id: SyncDeviceId, pub new_encrypted_key: String, pub expected_key_version: i32, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct BeginRotationResponse { rotation_id: uuid::Uuid, target_seq: i64, new_key_id: i32, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct RotationEntriesRequest { pub rotation_id: uuid::Uuid, pub after_seq: i64, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct RotationEntriesResponse { entries: Vec, has_more: bool, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct RotationEntry { seq: i64, data: Option, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct RotationBatchRequest { pub rotation_id: uuid::Uuid, pub entries: Vec, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct RotationBatchEntry { pub seq: i64, pub data: Option, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct RotationBatchResponse { updated_count: u64, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct CompleteRotationRequest { pub rotation_id: uuid::Uuid, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct CompleteRotationErrorResponse { remaining: i64, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BlobUploadUrlRequest { pub hash: String, pub size_bytes: i64, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct BlobUploadUrlResponse { upload_url: String, already_exists: bool, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BlobConfirmRequest { pub hash: String, pub size_bytes: i64, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BlobDownloadUrlRequest { pub hash: String, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct BlobDownloadUrlResponse { download_url: String, } // ── Developer billing types ── /// Request body for `POST /api/sync/apps/{id}/billing/activate`. Knob shape /// matches the columns after migration 118. /// /// In `enforcement_mode = "bulk"`: `storage_gb_cap` is required; `key_cap` and /// `gb_per_key` must be omitted. /// /// In `enforcement_mode = "per_key"`: `key_cap` AND `gb_per_key` are required; /// `storage_gb_cap` must be omitted. #[derive(Deserialize)] pub(crate) struct BillingActivateRequest { pub enforcement_mode: String, pub storage_gb_cap: Option, pub key_cap: Option, pub gb_per_key: Option, } /// Request body for `PATCH /api/sync/apps/{id}/billing`; same shape as /// activate. (Reused via alias for clarity at call sites.) pub(crate) type BillingPatchRequest = BillingActivateRequest; /// Response from `POST /api/sync/apps/{id}/billing/setup`. #[derive(Serialize)] pub(crate) struct BillingSetupResponse { pub stripe_customer_id: String, pub billing_portal_url: String, } /// Response from `POST /api/sync/apps/{id}/billing/activate` and /// `PATCH /api/sync/apps/{id}/billing`. #[derive(Serialize)] pub(crate) struct BillingUpdatedResponse { pub monthly_price_cents: i64, pub billing_status: String, pub stripe_subscription_id: Option, } /// Response from `GET /api/sync/apps/{id}/billing`. #[derive(Serialize)] pub(crate) struct BillingStatusResponse { pub app_id: SyncAppId, pub billing_status: String, pub is_internal: bool, pub enforcement_mode: String, pub storage_gb_cap: Option, pub key_cap: Option, pub gb_per_key: Option, pub bytes_stored: i64, /// Egress in the current billing period. Tracked for developer-facing /// stats only; egress is NOT a price input and NOT enforced as a cap. pub bytes_egress_period: i64, pub keys_claimed: u32, pub last_warning_pct: u8, pub current_period_start: Option>, pub current_period_end: Option>, /// Monthly price as computed by `synckit_billing::monthly_price_cents`. /// `None` while in draft (knobs not yet set). pub monthly_price_cents: Option, } // ── Key claim types ── /// Request body for `POST /api/sync/keys/claim`. Server-to-server: developer's /// backend sends the SyncKit app's `api_key` alongside the SDK key being /// claimed. #[derive(Deserialize)] pub(crate) struct ClaimKeyRequest { pub api_key: String, pub key: String, } /// Response body for `POST /api/sync/keys/claim`. #[derive(Serialize)] pub(crate) struct ClaimKeyResponse { pub newly_claimed: bool, pub total_claimed: i32, } /// Request body for `POST /api/sync/keys/release`. #[derive(Deserialize)] pub(crate) struct ReleaseKeyRequest { pub api_key: String, pub key: String, } /// Response body for `POST /api/sync/keys/release`. #[derive(Serialize)] pub(crate) struct ReleaseKeyResponse { pub newly_released: bool, pub total_claimed: i32, } /// Request body for `POST /api/sync/keys/list`. POST + body (not GET + query) /// to keep the api_key out of access logs. #[derive(Deserialize)] pub(crate) struct ListKeysRequest { pub api_key: String, pub limit: Option, pub offset: Option, } /// One row in the active-key list returned by `POST /api/sync/keys/list`. #[derive(Serialize)] pub(crate) struct KeyInfo { pub id: uuid::Uuid, pub key: String, pub claimed_at: DateTime, /// Bytes stored under this key (rolling counter, reconciled weekly by /// the drift job). `0` if no upload has confirmed yet for this key. pub bytes_stored: i64, } /// Response body for `POST /api/sync/keys/list`. #[derive(Serialize)] pub(crate) struct ListKeysResponse { pub keys: Vec, } /// Response for create/regenerate that includes the plaintext API key (shown only once). #[derive(Serialize)] pub(super) struct AppWithKey { #[serde(flatten)] pub app: db::DbSyncApp, /// The plaintext API key. Only returned on create and regenerate; not stored. pub api_key: String, } // ── Helper ── pub(super) fn generate_api_key() -> String { use rand::RngCore; let mut bytes = [0u8; constants::SYNCKIT_API_KEY_LENGTH]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } // ── Router ── /// Build the SyncKit route tree. /// /// Three route groups with different auth and rate-limiting strategies: /// /// - **Auth routes** (`/api/sync/auth`): Public, rate-limited per-second (IP) /// to prevent credential stuffing. /// - **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 (api_key in body, no JWT). .route("/api/sync/keys/claim", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::claim)) .route("/api/v1/sync/keys/claim", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::claim)) .route("/api/sync/keys/release", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::release)) .route("/api/v1/sync/keys/release", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::release)) .route("/api/sync/keys/list", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::list)) .route("/api/v1/sync/keys/list", post_csrf_skip(SYNCKIT_API_KEY_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 { config: 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)) .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/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)) // 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 { config: 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 { config: 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}/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) }