//! 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 groups; 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::{ AppState, constants, csrf::{ CsrfRouter, delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf, put_csrf_skip, }, db::{ self, SyncAppId, SyncDeviceId, SyncGroupId, SyncGroupInvitationId, SyncOperation, SyncPlatform, UserId, }, }; /// 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"; /// Longest client version string we will store. Matches the column width in /// migration 180; a longer value is a client we don't recognise, so it is /// dropped rather than truncated into something that reads like a real version. const CLIENT_VERSION_MAX_LENGTH: usize = 32; /// The SDK version out of a `synckit-client/` User-Agent, if the /// request carries one. /// /// Only the version is kept. A request from anything that is not the SDK (a /// browser, curl, an older client that sends no such header) yields `None`, and /// `None` is stored as-is: "syncing, version unknown" is a real answer and /// guessing would corrupt the field-version readout this exists to produce. /// The version is checked for shape, not parsed as semver, so a client that /// adds a pre-release suffix still reports. pub(crate) fn client_version(headers: &axum::http::HeaderMap) -> Option { let version = headers .get(axum::http::header::USER_AGENT)? .to_str() .ok()? .split_whitespace() .next()? .strip_prefix("synckit-client/")?; let ok = !version.is_empty() && version.len() <= CLIENT_VERSION_MAX_LENGTH && version.starts_with(|c: char| c.is_ascii_digit()) && version .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_')); ok.then(|| version.to_string()) } // ── 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, /// For a group entry, the GCK generation its ciphertext is sealed under. The /// member resolves that generation's grant to decrypt it, which is how entries /// written before a rotation stay readable. Absent on personal entries, which /// key off `key_id` instead. #[serde(skip_serializing_if = "Option::is_none")] gck_version: 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, } // ── Group types ── #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct CreateGroupRequest { /// The group id, generated client-side. The admin seals the GCK grant bound /// to this id before the group exists (the grant's AAD binds the group id), so /// the id must be chosen by the client, not the server. A UUID collision (PK /// conflict) is rejected. #[schema(value_type = String)] pub id: SyncGroupId, pub name: String, /// The Group Content Key sealed to the creating admin's own identity public /// key (base64), opaque to the server. pub admin_sealed_gck: String, /// The admin's own identity public key (base64), stored so the GCK can be /// re-sealed to the admin on a later rotation. pub admin_pubkey: String, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct GroupResponse { #[schema(value_type = String)] id: SyncGroupId, #[schema(value_type = String)] app_id: SyncAppId, #[schema(value_type = String)] admin_user_id: UserId, name: String, gck_version: i32, #[schema(value_type = String)] created_at: DateTime, } impl From for GroupResponse { fn from(g: db::DbSyncGroup) -> Self { Self { id: g.id, app_id: g.app_id, admin_user_id: g.admin_user_id, name: g.name, gck_version: g.gck_version, created_at: g.created_at, } } } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct AddMemberRequest { /// The member's account email, resolved to a verified user server-side. pub member_email: String, /// The GCK sealed to the member's identity public key (base64), produced by /// the admin with the group's current GCK. Opaque to the server. pub sealed_gck: String, /// The member's identity public key (base64), stored so the GCK can be /// re-sealed to them on a later rotation. pub member_pubkey: String, /// Optional role: "member" (default) or "admin". #[serde(default)] pub role: Option, } /// Issue an invite link, from `POST /groups/{id}/invitations`. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct CreateInvitationRequest { /// How long the link stays redeemable, in hours. Clamped server-side; an /// omitted value takes the default. An unredeemed invitation always expires, /// so there is no "never" to ask for. #[serde(default)] pub expires_in_hours: Option, } /// A freshly issued invitation. The token appears here and nowhere else: the /// server keeps only its hash, so this response is the single opportunity to /// capture it. #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct CreateInvitationResponse { #[schema(value_type = String)] pub id: SyncGroupInvitationId, /// The one-use token, to be carried in the link the admin sends. pub token: String, #[schema(value_type = String)] pub expires_at: DateTime, } /// Accept an invitation, from `POST /sync/invitations/accept`. /// /// Not nested under the group: the invitee is not a member yet and cannot be /// asked to know a group id they have no access to. The token names the group. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct AcceptInvitationRequest { /// The one-use token from the link. pub token: String, /// The accepting user's identity public key (base64). What the admin will /// seal the group key to, once they have confirmed its fingerprint. pub invitee_pubkey: String, } /// What an invitee is shown before accepting, from /// `GET /sync/invitations/{token}`. /// /// Deliberately thin. It answers "which group, from whom, is this still good" /// and nothing else, because it is readable by anyone holding the link. #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct InvitationPreviewResponse { pub group_name: String, /// The inviting admin's email, so the invitee can tell whether the link came /// from who they think it did. pub inviter_email: String, /// Whether the token can still be accepted. False covers expired, revoked, /// redeemed, and already-accepted alike; the reason is in `state`. pub redeemable: bool, /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`. pub state: String, #[schema(value_type = String)] pub expires_at: DateTime, } /// Confirm an accepted invitation, from /// `POST /groups/{id}/invitations/{invitation_id}/confirm`. /// /// No public key here on purpose. The grant is sealed to the key recorded on the /// invitation, so the key the admin confirmed is the key that gets used; letting /// the caller re-supply one would reintroduce the substitution the confirmation /// step exists to catch. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct ConfirmInvitationRequest { /// The group's current GCK sealed to the invitee's recorded public key /// (base64). Opaque to the server. pub sealed_gck: String, /// Optional role: "member" (default) or "admin". #[serde(default)] pub role: Option, } /// One invitation in the admin's list, from `GET /groups/{id}/invitations`. /// /// Carries the invitee's public key so the admin's client can render its /// fingerprint for the out-of-band check. The token is absent: the server does /// not have it. #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct InvitationResponse { #[schema(value_type = String)] pub id: SyncGroupInvitationId, /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`. pub state: String, /// The accepting account's email, or `None` while outstanding. pub invitee_email: Option, /// The accepting account's identity public key (base64), or `None` while /// outstanding. The admin seals the GCK to this after confirming it. pub invitee_pubkey: Option, #[schema(value_type = String)] pub expires_at: DateTime, #[schema(value_type = String)] pub created_at: DateTime, } /// The lifecycle state of an invitation as one word. /// /// Expiry is derived rather than stored as a state, so a row does not need /// touching when its deadline passes. Order matters: a redeemed or revoked /// invitation reports as such even after its expiry, because what happened to it /// is more informative than the clock running out afterwards. pub(crate) fn invitation_state(inv: &db::DbSyncGroupInvitation) -> &'static str { if inv.redeemed_at.is_some() { "redeemed" } else if inv.revoked_at.is_some() { "revoked" } else if inv.accepted_at.is_some() { "accepted" } else if inv.expires_at <= Utc::now() { "expired" } else { "pending" } } impl From for InvitationResponse { fn from(inv: db::DbSyncGroupInvitation) -> Self { let state = invitation_state(&inv); Self { id: inv.id, state: state.to_string(), invitee_email: inv.invitee_email, invitee_pubkey: inv.invitee_pubkey, expires_at: inv.expires_at, created_at: inv.created_at, } } } /// One member's identity public key, from `GET /groups/{id}/pubkeys`. The admin /// re-seals a rotated GCK to each of these. #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct GroupMemberPubkey { #[schema(value_type = String)] user_id: UserId, pubkey: String, } /// Query for `GET /groups/{id}/grant`: which GCK generation to fetch. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct GrantQuery { /// The generation wanted. Omitted means the newest the caller holds. #[serde(default)] pub version: Option, } /// One member's re-sealed grant in a rotation batch. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct RotateGrant { #[schema(value_type = String)] pub user_id: UserId, /// The new GCK sealed to this member's stored identity public key (base64). /// Opaque to the server. pub sealed_gck: String, } /// Rotate a group's GCK, from `POST /groups/{id}/rotate`. /// /// The grant set is the new membership: anyone holding a grant today and absent /// here is removed by the rotation. That is what makes removal and re-key one /// transaction rather than two calls with a window between them. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct RotateGroupKeyRequest { /// The new GCK generation. Must be greater than the group's current one, so /// a replayed or stale rotation cannot roll the group back onto a key a /// removed member still holds. pub gck_version: i32, /// Every remaining member's re-sealed grant, including the admin's own. pub grants: Vec, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct GroupMemberResponse { #[schema(value_type = String)] user_id: UserId, /// The member's account email, so an admin panel can identify them. email: String, role: String, #[schema(value_type = String)] added_at: DateTime, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct GroupGrantResponse { /// The caller's sealed GCK grant (base64); opened client-side with the /// member's identity private key. sealed_gck: String, /// The GCK generation this grant was sealed under. gck_version: i32, } /// 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, /// Source table and row id, echoed so the client can recompute the entry's /// AEAD associated data when re-encrypting under the new key. table: String, row_id: String, 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, // The confirm handler reads the authoritative object size from S3 and does // not trust a client-declared size. Clients may still send `size_bytes`; it // is ignored by deserialization (no `deny_unknown_fields`). } /// Open a multipart blob session. `size_bytes` is the *ciphertext* length, /// which the client knows before sealing anything (`blob_encrypted_len`), so /// both sides derive the same part geometry from it without a round trip. #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BlobMultipartStartRequest { pub hash: String, pub size_bytes: i64, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct BlobMultipartStartResponse { upload_id: String, part_size: usize, part_count: u32, /// Same dedup short-circuit as the one-shot upload: when true no session /// was opened and the other fields are empty. already_exists: bool, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BlobMultipartPartsRequest { pub hash: String, pub upload_id: String, /// Must match the `size_bytes` passed to `start`, the plan is deterministic /// in it, and a different value would sign the wrong `Content-Length`s. pub size_bytes: i64, pub first_part: u32, pub count: u32, /// SHA-256 of each requested part's bytes, base64 of the raw digest, /// positionally aligned with `first_part..first_part + count`. Bound into /// the presigned URL so S3 rehashes the part and rejects a mismatch at write /// time. Optional for now, since a client can only supply a checksum for a /// part it has already built, which in practice means asking for one part /// at a time. When present the length must equal `count`. #[serde(default)] pub checksums: Option>, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct BlobMultipartPartUrl { part_number: i32, content_length: u64, url: String, } #[derive(Serialize, utoipa::ToSchema)] pub(crate) struct BlobMultipartPartsResponse { parts: Vec, expires_in: u64, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BlobMultipartCompletedPart { pub part_number: i32, pub etag: String, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BlobMultipartCompleteRequest { pub hash: String, pub upload_id: String, pub parts: Vec, } #[derive(Deserialize, utoipa::ToSchema)] pub(crate) struct BlobMultipartAbortRequest { pub hash: String, pub upload_id: String, } #[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 app's keys-endpoint secret alongside the SDK key being /// claimed. /// /// `app_secret`, not `api_key`: the api_key is compiled into shipped clients /// and so cannot gate an endpoint that spends the app's key cap. #[derive(Deserialize)] pub(crate) struct ClaimKeyRequest { pub app_secret: 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 app_secret: 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 secret out of access logs. #[derive(Deserialize)] pub(crate) struct ListKeysRequest { pub app_secret: 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, } /// Response for generating/rotating the keys-endpoint secret. The plaintext is /// returned once and never again, only its hash is stored. #[derive(Serialize)] pub(super) struct AppKeysSecret { #[serde(flatten)] pub app: db::DbSyncApp, /// The plaintext secret. Keep it on a developer backend; putting it in a /// shipped client reintroduces exactly the weakness it exists to close. pub app_secret: String, } // ── Helper ── pub(super) fn generate_api_key() -> String { use rand::Rng; let mut bytes = [0u8; constants::SYNCKIT_API_KEY_LENGTH]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } /// Generate the keys-endpoint secret. Same shape and entropy as an api_key; /// what differs is where it is allowed to live, a developer backend only, /// never compiled into a shipped client. pub(super) fn generate_app_secret() -> String { generate_api_key() } // ── 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 (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) } #[cfg(test)] mod tests { use super::client_version; use axum::http::{HeaderMap, HeaderValue, header::USER_AGENT}; fn ua(value: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap()); headers } #[test] fn reads_the_sdk_version() { assert_eq!( client_version(&ua("synckit-client/0.6.0")).as_deref(), Some("0.6.0") ); // Pre-release and build suffixes are real versions, keep them whole. assert_eq!( client_version(&ua("synckit-client/1.0.0-rc.2")).as_deref(), Some("1.0.0-rc.2") ); // A consumer app appending its own product token must not break the read. assert_eq!( client_version(&ua("synckit-client/0.6.0 audiofiles/0.9.1")).as_deref(), Some("0.6.0") ); } #[test] fn ignores_anything_that_is_not_the_sdk() { assert_eq!(client_version(&HeaderMap::new()), None); assert_eq!(client_version(&ua("curl/8.5.0")), None); assert_eq!(client_version(&ua("Mozilla/5.0 (X11; Linux x86_64)")), None); // Right prefix, no version. assert_eq!(client_version(&ua("synckit-client/")), None); // Prefix match must be exact, not a substring of some other product. assert_eq!(client_version(&ua("evil-synckit-client/9.9.9")), None); } #[test] fn rejects_junk_rather_than_storing_it() { // Over the column width: dropped, not truncated into a plausible-looking // version. let long = format!("synckit-client/{}", "9".repeat(64)); assert_eq!(client_version(&ua(&long)), None); // A version has to start with a digit, so a free-text string cannot // smuggle itself into the readout. assert_eq!(client_version(&ua("synckit-client/not-a-version")), None); assert_eq!(client_version(&ua("synckit-client/../../etc/passwd")), None); // At the boundary it is kept. let at_max = format!("synckit-client/1{}", "0".repeat(31)); assert!(client_version(&ua(&at_max)).is_some()); } }