diff --git a/Cargo.toml b/Cargo.toml
index a4e682b..0a50608 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ members = [
"crates/ely_design_system",
"crates/ely_domain",
"crates/ely_servo_host",
+ "crates/ely_sync_client",
]
default-members = ["crates/ely_app"]
resolver = "2"
diff --git a/crates/ely_sync_client/Cargo.toml b/crates/ely_sync_client/Cargo.toml
new file mode 100644
index 0000000..5be8b37
--- /dev/null
+++ b/crates/ely_sync_client/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "ely_sync_client"
+version = "0.1.0"
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+ely_domain = { path = "../ely_domain" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+sha2 = { workspace = true }
+thiserror = { workspace = true }
+ureq = { workspace = true, features = ["json"] }
+uuid = { workspace = true }
+
+[lints]
+workspace = true
diff --git a/crates/ely_sync_client/src/auth.rs b/crates/ely_sync_client/src/auth.rs
new file mode 100644
index 0000000..3f198ff
--- /dev/null
+++ b/crates/ely_sync_client/src/auth.rs
@@ -0,0 +1,134 @@
+use std::{
+ fs,
+ io::{self, ErrorKind},
+ path::{Path, PathBuf},
+};
+
+use crate::error::SyncClientError;
+
+/// Better Auth bearer token issued by `https:///api/auth/*`. The
+/// token grants access to the per-user `withAuthenticatedApiControls`
+/// routes and, once the device is bound to the session, to the
+/// `withApprovedDeviceApiControls` routes used by sync.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct BearerToken(String);
+
+impl BearerToken {
+ /// Construct a token from an existing string. Trims whitespace and
+ /// enforces the same character envelope the worker validates so
+ /// obviously-malformed tokens fail before we hit the network.
+ pub fn new(value: impl Into) -> Result {
+ let value = value.into();
+ let trimmed = value.trim();
+ if trimmed.is_empty() || !is_better_auth_bearer(trimmed) {
+ return Err(SyncClientError::TokenStorage(
+ "bearer token is not a Better Auth session token".to_string(),
+ ));
+ }
+ Ok(Self(trimmed.to_string()))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+fn is_better_auth_bearer(token: &str) -> bool {
+ let length_ok = (32..=4096).contains(&token.len());
+ let charset_ok = token
+ .as_bytes()
+ .iter()
+ .all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'~' | b'+' | b'/' | b'=' | b'-'));
+ length_ok && charset_ok
+}
+
+/// File-backed bearer token store. Lives in the per-profile data
+/// directory so a private window never inherits the standard
+/// profile's session — same isolation the rest of the runtime
+/// enforces. Writes go through a temp-rename so a partial write
+/// can't corrupt the persisted token.
+#[derive(Clone, Debug)]
+pub struct BearerTokenStore {
+ path: PathBuf,
+}
+
+impl BearerTokenStore {
+ pub fn new(path: PathBuf) -> Self {
+ Self { path }
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub fn load(&self) -> Result