Skip to main content

max / synckit

Send the SDK version as User-Agent on sync requests SyncKit is client-side only, so there is no deployed version to poll and "which SyncKit is in the field" had no answer. Every request now carries `synckit-client/<version>`; the server records it per registered device. Version only: no OS string, no host name, no consumer-app name.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 19:23 UTC
Signed with PGP, not checked
Commit: 731fb67891c3d512506a343da8614661776e5201
Parent: c97efef
1 file changed, +20 insertions, -0 deletions
@@ -97,6 +97,13 @@
97 97 /// an idle notification stream is never mistaken for a stall.
98 98 const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(90);
99 99
100 + /// User-Agent sent on every request this SDK makes. The version is the only
101 + /// thing identified: no OS string, no host name, no consumer-app name. It
102 + /// answers "which SyncKit is actually in the field", which a lockfile read at
103 + /// release time cannot. The server persists it per registered device (see
104 + /// `sync_devices.client_version`), it does not log it per request.
105 + const USER_AGENT: &str = concat!("synckit-client/", env!("CARGO_PKG_VERSION"));
106 +
100 107 /// Configuration for the SyncKit client.
101 108 #[derive(Debug, Clone)]
102 109 pub struct SyncKitConfig {
@@ -342,6 +349,7 @@
342 349
343 350 let https_only = requires_https(&config.server_url);
344 351 let http = Client::builder()
352 + .user_agent(USER_AGENT)
345 353 .timeout(Duration::from_secs(30))
346 354 .connect_timeout(Duration::from_secs(10))
347 355 .pool_max_idle_per_host(5)
@@ -359,6 +367,7 @@
359 367 // above the server's 30s SSE keepalive, so an idle notification stream
360 368 // (which receives a keepalive comment every 30s) is never torn down.
361 369 let http_stream = Client::builder()
370 + .user_agent(USER_AGENT)
362 371 .connect_timeout(Duration::from_secs(10))
363 372 .read_timeout(STREAM_READ_TIMEOUT)
364 373 .pool_max_idle_per_host(5)
@@ -529,6 +538,7 @@
529 538 pub async fn validate_api_key(server_url: &str, api_key: &str) -> Result<String> {
530 539 let url = format!("{server_url}/api/v1/sync/validate-app");
531 540 let http = reqwest::Client::builder()
541 + .user_agent(USER_AGENT)
532 542 .timeout(std::time::Duration::from_secs(10))
533 543 .https_only(requires_https(server_url))
534 544 .build()?;
@@ -566,6 +576,16 @@
566 576 use super::*;
567 577 use base64::Engine;
568 578
579 + #[test]
580 + fn user_agent_is_name_slash_version() {
581 + // The server parses on the `/` and stores the right-hand side, so the
582 + // shape is a wire contract, not cosmetic.
583 + let (name, version) = USER_AGENT.split_once('/').expect("no `/` in User-Agent");
584 + assert_eq!(name, "synckit-client");
585 + assert_eq!(version, env!("CARGO_PKG_VERSION"));
586 + assert!(version.starts_with(char::is_numeric));
587 + }
588 +
569 589 #[test]
570 590 fn requires_https_enforces_tls_off_loopback() {
571 591 // https or non-loopback http must enforce TLS.