Add ely_sync_client crate
Build the Rust counterpart to `ely-browser-cloud`: a Bearer-token authenticated HTTP client with the JSON wire types for the worker's device + snapshot routes. What lands: - `BearerToken` + `BearerTokenStore` so Better Auth sessions persist per profile data dir with atomic rename writes. - `DeviceIdentity` (UUIDv7 + Ed25519-shaped public key, persisted alongside the token so the worker keeps the same `device_id` across restarts). - `SyncApiClient` with `register_device`, `list_devices`, `upload_snapshot`, and `download_snapshot` over `ureq`, mapping the worker's strict error envelopes onto typed `SyncClientError`s. - `SnapshotPayload` enforces the worker's 10 MiB / SHA-256-hash contract before the wire encode, so callers fail fast. Out of scope for this commit: the BrowserCore integration that swaps snapshots in and out, and the in-app Better Auth + device-approval UX. Those land in subsequent commits — `cloudflare/src/api_controls.ts` rejects sync from devices that aren't already approved, so first-use also requires a one-shot D1 approval until that path exists in the UI.
This commit is contained in:
@@ -5,6 +5,7 @@ members = [
|
|||||||
"crates/ely_design_system",
|
"crates/ely_design_system",
|
||||||
"crates/ely_domain",
|
"crates/ely_domain",
|
||||||
"crates/ely_servo_host",
|
"crates/ely_servo_host",
|
||||||
|
"crates/ely_sync_client",
|
||||||
]
|
]
|
||||||
default-members = ["crates/ely_app"]
|
default-members = ["crates/ely_app"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::{self, ErrorKind},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::SyncClientError;
|
||||||
|
|
||||||
|
/// Better Auth bearer token issued by `https://<base>/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<String>) -> Result<Self, SyncClientError> {
|
||||||
|
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<Option<BearerToken>, SyncClientError> {
|
||||||
|
match fs::read_to_string(&self.path) {
|
||||||
|
Ok(contents) => {
|
||||||
|
if contents.trim().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
BearerToken::new(contents).map(Some)
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, token: &BearerToken) -> Result<(), SyncClientError> {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(io_err)?;
|
||||||
|
}
|
||||||
|
let tmp = self.path.with_extension("tmp");
|
||||||
|
fs::write(&tmp, token.as_str()).map_err(io_err)?;
|
||||||
|
fs::rename(&tmp, &self.path).map_err(io_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&self) -> Result<(), SyncClientError> {
|
||||||
|
match fs::remove_file(&self.path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_err(error: io::Error) -> SyncClientError {
|
||||||
|
SyncClientError::TokenStorage(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::env::temp_dir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_obviously_broken_tokens() {
|
||||||
|
assert!(BearerToken::new("").is_err());
|
||||||
|
assert!(BearerToken::new(" ").is_err());
|
||||||
|
assert!(BearerToken::new("short").is_err());
|
||||||
|
// Spaces are not in the Better Auth bearer charset.
|
||||||
|
assert!(BearerToken::new(format!("{}aaa bb", "a".repeat(40))).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_better_auth_shape() -> Result<(), SyncClientError> {
|
||||||
|
let token = format!("{}-{}_{}", "a".repeat(20), "b".repeat(20), "c".repeat(20));
|
||||||
|
BearerToken::new(token).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_store_round_trips() -> Result<(), SyncClientError> {
|
||||||
|
let dir = temp_dir().join(format!("ely-token-{}", uuid::Uuid::now_v7().simple()));
|
||||||
|
let store = BearerTokenStore::new(dir.join("token"));
|
||||||
|
let token = BearerToken::new("a".repeat(64))?;
|
||||||
|
assert_eq!(store.load()?, None);
|
||||||
|
|
||||||
|
store.save(&token)?;
|
||||||
|
assert_eq!(store.load()?, Some(token.clone()));
|
||||||
|
|
||||||
|
store.clear()?;
|
||||||
|
assert_eq!(store.load()?, None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use ureq::{Agent, AgentBuilder};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::BearerToken,
|
||||||
|
device::{DeviceIdentity, DeviceListResponse, DeviceRegistration},
|
||||||
|
error::SyncClientError,
|
||||||
|
snapshot::{SnapshotDownload, SnapshotUploadRequest},
|
||||||
|
};
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!("ELY Browser/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
/// Resolved configuration for talking to a worker deployment.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ApiClientConfig {
|
||||||
|
base_url: String,
|
||||||
|
region: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClientConfig {
|
||||||
|
/// Build the config used by the production deployment. The base URL
|
||||||
|
/// matches `wrangler.toml`'s `ELY_AUTH_BASE_URL`.
|
||||||
|
pub fn production() -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: "https://ely-browser-cloud.zhangyanghaha0407.workers.dev".to_string(),
|
||||||
|
region: "auto".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn custom(base_url: impl Into<String>, region: impl Into<String>) -> Self {
|
||||||
|
Self { base_url: base_url.into(), region: region.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> &str {
|
||||||
|
&self.base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn region(&self) -> &str {
|
||||||
|
&self.region
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearer-token authenticated HTTP client targeting `ely-browser-cloud`.
|
||||||
|
pub struct SyncApiClient {
|
||||||
|
agent: Agent,
|
||||||
|
config: ApiClientConfig,
|
||||||
|
bearer: BearerToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncApiClient {
|
||||||
|
pub fn new(config: ApiClientConfig, bearer: BearerToken) -> Result<Self, SyncClientError> {
|
||||||
|
if !config.base_url.starts_with("https://") && !config.base_url.starts_with("http://") {
|
||||||
|
return Err(SyncClientError::InvalidBaseUrl { url: config.base_url.clone() });
|
||||||
|
}
|
||||||
|
let agent = AgentBuilder::new().timeout(REQUEST_TIMEOUT).user_agent(USER_AGENT).build();
|
||||||
|
Ok(Self { agent, config, bearer })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> &ApiClientConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/devices/register` — bind a freshly-generated device
|
||||||
|
/// identity to the current session. The response carries the
|
||||||
|
/// canonical device record from the worker's `user_devices` table,
|
||||||
|
/// including the `approval_status` that gates `/api/sync/*`.
|
||||||
|
pub fn register_device(
|
||||||
|
&self,
|
||||||
|
identity: &DeviceIdentity,
|
||||||
|
idempotency_key: &str,
|
||||||
|
) -> Result<DeviceRecordDocument, SyncClientError> {
|
||||||
|
let registration = DeviceRegistration {
|
||||||
|
device_id: &identity.device_id,
|
||||||
|
public_key: &identity.public_key,
|
||||||
|
device_name: &identity.device_name,
|
||||||
|
platform: &identity.platform,
|
||||||
|
idempotency_key,
|
||||||
|
};
|
||||||
|
let endpoint = self.endpoint("/api/devices/register");
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.post(&endpoint)
|
||||||
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.send_json(serde_json::to_value(®istration).map_err(|error| {
|
||||||
|
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
|
||||||
|
})?);
|
||||||
|
let body = read_json_response::<DeviceRecordDocument>(&endpoint, response)?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/devices` — return every device bound to the user,
|
||||||
|
/// approved or otherwise. Used by the UI to render the pending
|
||||||
|
/// device-approval list.
|
||||||
|
pub fn list_devices(&self) -> Result<DeviceListResponse, SyncClientError> {
|
||||||
|
let endpoint = self.endpoint("/api/devices");
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.get(&endpoint)
|
||||||
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
||||||
|
.call();
|
||||||
|
read_json_response::<DeviceListResponse>(&endpoint, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sync/snapshot` — push the full per-user state. The
|
||||||
|
/// worker enforces logical-clock monotonicity, so callers must
|
||||||
|
/// pass a value strictly greater than the last accepted snapshot.
|
||||||
|
pub fn upload_snapshot(
|
||||||
|
&self,
|
||||||
|
request: &SnapshotUploadRequest<'_>,
|
||||||
|
) -> Result<SnapshotUploadDocument, SyncClientError> {
|
||||||
|
let endpoint = self.endpoint("/api/sync/snapshot");
|
||||||
|
let response =
|
||||||
|
self.agent
|
||||||
|
.post(&endpoint)
|
||||||
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.send_json(serde_json::to_value(request).map_err(|error| {
|
||||||
|
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
|
||||||
|
})?);
|
||||||
|
read_json_response::<SnapshotUploadDocument>(&endpoint, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/sync/snapshot?snapshot_id=…` — fetch the snapshot for
|
||||||
|
/// the named id. Returns the encoded payload plus the snapshot
|
||||||
|
/// metadata; callers should verify the payload hash before trusting
|
||||||
|
/// the bytes.
|
||||||
|
pub fn download_snapshot(
|
||||||
|
&self,
|
||||||
|
snapshot_id: &str,
|
||||||
|
) -> Result<SnapshotDownload, SyncClientError> {
|
||||||
|
let endpoint = self.endpoint(&format!("/api/sync/snapshot?snapshot_id={snapshot_id}"));
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.get(&endpoint)
|
||||||
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
||||||
|
.call();
|
||||||
|
read_json_response::<SnapshotDownload>(&endpoint, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.config.base_url.trim_end_matches('/'), path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
|
pub struct DeviceRecordDocument {
|
||||||
|
pub version: u32,
|
||||||
|
pub user_id: String,
|
||||||
|
pub device: crate::device::DeviceRecord,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
|
pub struct SnapshotUploadDocument {
|
||||||
|
pub version: u32,
|
||||||
|
pub user_id: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub snapshot: crate::snapshot::SnapshotDocument,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_response<T: DeserializeOwned>(
|
||||||
|
endpoint: &str,
|
||||||
|
response: Result<ureq::Response, ureq::Error>,
|
||||||
|
) -> Result<T, SyncClientError> {
|
||||||
|
match response {
|
||||||
|
Ok(ok) => {
|
||||||
|
let status = ok.status();
|
||||||
|
let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus {
|
||||||
|
endpoint: endpoint.to_string(),
|
||||||
|
status,
|
||||||
|
body: error.to_string(),
|
||||||
|
})?;
|
||||||
|
serde_json::from_str::<T>(&body).map_err(|error| SyncClientError::Json {
|
||||||
|
endpoint: endpoint.to_string(),
|
||||||
|
source: error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(ureq::Error::Status(status, raw)) => {
|
||||||
|
let body = raw.into_string().unwrap_or_default();
|
||||||
|
Err(SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body })
|
||||||
|
}
|
||||||
|
Err(other) => {
|
||||||
|
Err(SyncClientError::Http { endpoint: endpoint.to_string(), source: Box::new(other) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::{self, ErrorKind},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::SyncClientError;
|
||||||
|
|
||||||
|
/// Locally-stable device identity. Constructed once per profile data
|
||||||
|
/// directory and persisted so reinstalls don't trigger re-approval
|
||||||
|
/// requests — the same `device_id` is reused across runs.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceIdentity {
|
||||||
|
pub device_id: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub device_name: String,
|
||||||
|
pub platform: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceIdentity {
|
||||||
|
/// Load the persisted identity, or create-and-save a new one.
|
||||||
|
/// The identity file lives at `path`; callers usually pick
|
||||||
|
/// `<profile_data>/sync/device.json`.
|
||||||
|
pub fn load_or_create(
|
||||||
|
path: &Path,
|
||||||
|
device_name: impl Into<String>,
|
||||||
|
platform: impl Into<String>,
|
||||||
|
) -> Result<Self, SyncClientError> {
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(contents) => {
|
||||||
|
let identity: Self = serde_json::from_str(&contents).map_err(|error| {
|
||||||
|
SyncClientError::TokenStorage(format!(
|
||||||
|
"device identity is corrupt at {}: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
identity.validate()?;
|
||||||
|
Ok(identity)
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
|
let identity = Self::generate(device_name, platform);
|
||||||
|
identity.save(path)?;
|
||||||
|
Ok(identity)
|
||||||
|
}
|
||||||
|
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(device_name: impl Into<String>, platform: impl Into<String>) -> Self {
|
||||||
|
let device_id = format!("ely-{}", Uuid::now_v7().simple());
|
||||||
|
// Placeholder public key — Ed25519 device-bound signing is a
|
||||||
|
// backend feature still in design. The worker validates the
|
||||||
|
// shape but does not currently challenge it.
|
||||||
|
let public_key = format!("ed25519:{}", Uuid::now_v7().simple());
|
||||||
|
Self { device_id, public_key, device_name: device_name.into(), platform: platform.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<(), SyncClientError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(io_err)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
let serialized = serde_json::to_string_pretty(self).map_err(|error| {
|
||||||
|
SyncClientError::TokenStorage(format!("device identity serialize: {error}"))
|
||||||
|
})?;
|
||||||
|
fs::write(&tmp, serialized).map_err(io_err)?;
|
||||||
|
fs::rename(&tmp, path).map_err(io_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), SyncClientError> {
|
||||||
|
if !is_device_id_shape(&self.device_id) {
|
||||||
|
return Err(SyncClientError::TokenStorage(
|
||||||
|
"device_id does not match the Cloudflare worker pattern".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.public_key.trim().is_empty() {
|
||||||
|
return Err(SyncClientError::TokenStorage("device public_key is empty".to_string()));
|
||||||
|
}
|
||||||
|
if self.device_name.trim().is_empty() {
|
||||||
|
return Err(SyncClientError::TokenStorage("device_name is empty".to_string()));
|
||||||
|
}
|
||||||
|
if self.platform.trim().is_empty() {
|
||||||
|
return Err(SyncClientError::TokenStorage("platform is empty".to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_device_id_shape(value: &str) -> bool {
|
||||||
|
(3..=128).contains(&value.len())
|
||||||
|
&& value
|
||||||
|
.as_bytes()
|
||||||
|
.iter()
|
||||||
|
.all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b':' | b'-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_err(error: io::Error) -> SyncClientError {
|
||||||
|
SyncClientError::TokenStorage(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct DeviceRegistration<'a> {
|
||||||
|
pub device_id: &'a str,
|
||||||
|
pub public_key: &'a str,
|
||||||
|
pub device_name: &'a str,
|
||||||
|
pub platform: &'a str,
|
||||||
|
pub idempotency_key: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct DeviceListResponse {
|
||||||
|
pub version: u32,
|
||||||
|
pub user_id: String,
|
||||||
|
pub devices: Vec<DeviceRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct DeviceRecord {
|
||||||
|
pub device_id: String,
|
||||||
|
pub device_name: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub approval_status: String,
|
||||||
|
pub created_at: u64,
|
||||||
|
pub approved_at: Option<u64>,
|
||||||
|
pub last_active_at: Option<u64>,
|
||||||
|
pub revoked_at: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceRecord {
|
||||||
|
pub fn is_approved(&self) -> bool {
|
||||||
|
self.approval_status == "approved" && self.revoked_at.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::env::temp_dir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_round_trips() -> Result<(), SyncClientError> {
|
||||||
|
let dir = temp_dir().join(format!("ely-device-{}", Uuid::now_v7().simple()));
|
||||||
|
let path = dir.join("device.json");
|
||||||
|
let identity = DeviceIdentity::load_or_create(&path, "Test", "macos")?;
|
||||||
|
identity.validate()?;
|
||||||
|
|
||||||
|
let again = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?;
|
||||||
|
assert_eq!(identity, again);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SyncClientError {
|
||||||
|
#[error("API base URL is invalid: {url}")]
|
||||||
|
InvalidBaseUrl { url: String },
|
||||||
|
|
||||||
|
#[error("Bearer token storage is unavailable: {0}")]
|
||||||
|
TokenStorage(String),
|
||||||
|
|
||||||
|
#[error("HTTP request failed for {endpoint}: {source}")]
|
||||||
|
Http {
|
||||||
|
endpoint: String,
|
||||||
|
#[source]
|
||||||
|
source: Box<ureq::Error>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("HTTP {status} {endpoint}: {body}")]
|
||||||
|
HttpStatus { endpoint: String, status: u16, body: String },
|
||||||
|
|
||||||
|
#[error("JSON parsing failed for {endpoint}: {source}")]
|
||||||
|
Json {
|
||||||
|
endpoint: String,
|
||||||
|
#[source]
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("Snapshot payload is too large: {bytes} bytes (max {limit})")]
|
||||||
|
SnapshotTooLarge { bytes: usize, limit: usize },
|
||||||
|
|
||||||
|
#[error("Snapshot base64 decode failed: {0}")]
|
||||||
|
SnapshotBase64(String),
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! HTTP client for the `ely-browser-cloud` Cloudflare worker.
|
||||||
|
//!
|
||||||
|
//! The worker exposes the device-bound sync API documented in
|
||||||
|
//! `cloudflare/src/sync_*.ts`. This crate is the renderer-side counterpart:
|
||||||
|
//! it owns the Better Auth bearer token, the locally-generated device
|
||||||
|
//! identity, and the JSON wire types needed to push and pull the user's
|
||||||
|
//! browser state.
|
||||||
|
//!
|
||||||
|
//! Scope today:
|
||||||
|
//! - Bearer-token authenticated requests via `ureq`.
|
||||||
|
//! - Device registration (`POST /api/devices/register`) and listing
|
||||||
|
//! (`GET /api/devices`).
|
||||||
|
//! - Sync snapshot upload (`POST /api/sync/snapshot`) and download
|
||||||
|
//! (`GET /api/sync/snapshot?snapshot_id=…`).
|
||||||
|
//!
|
||||||
|
//! Intentionally omitted (kept for follow-up work, not papered over here):
|
||||||
|
//! - The full Better Auth handshake (email + OTP / OAuth). Callers obtain
|
||||||
|
//! the bearer token out-of-band and hand it to the client.
|
||||||
|
//! - First-device approval bootstrap. The Cloudflare API rejects sync from
|
||||||
|
//! an unapproved device; the user must approve a freshly-registered
|
||||||
|
//! device from another already-approved device (or via direct D1
|
||||||
|
//! operation), exactly as the backend enforces.
|
||||||
|
//! - Incremental change-log push/pull (`/api/sync/push` and `/api/sync/pull`).
|
||||||
|
//! The snapshot path is the simplest contract that round-trips the user's
|
||||||
|
//! entire state, so we start there.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod client;
|
||||||
|
pub mod device;
|
||||||
|
pub mod error;
|
||||||
|
pub mod snapshot;
|
||||||
|
|
||||||
|
pub use auth::{BearerToken, BearerTokenStore};
|
||||||
|
pub use client::{ApiClientConfig, SyncApiClient};
|
||||||
|
pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration};
|
||||||
|
pub use error::SyncClientError;
|
||||||
|
pub use snapshot::{SnapshotDownload, SnapshotPayload, SnapshotUploadRequest};
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::error::SyncClientError;
|
||||||
|
|
||||||
|
/// Hard cap from `cloudflare/src/sync_snapshot.ts`: a single snapshot
|
||||||
|
/// upload may not exceed 10 MiB. We enforce the same limit client-side
|
||||||
|
/// so the user sees a typed error instead of a generic HTTP 400.
|
||||||
|
pub const MAX_SNAPSHOT_BYTES: usize = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Validated snapshot payload, ready to ship to `/api/sync/snapshot`.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SnapshotPayload {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
payload_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SnapshotPayload {
|
||||||
|
/// Wrap `bytes` and pre-compute the SHA-256 the worker validates
|
||||||
|
/// before persisting the R2 object. Rejects payloads above the
|
||||||
|
/// 10 MiB limit so the caller sees the failure before any
|
||||||
|
/// network IO.
|
||||||
|
pub fn new(bytes: Vec<u8>) -> Result<Self, SyncClientError> {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err(SyncClientError::SnapshotTooLarge { bytes: 0, limit: MAX_SNAPSHOT_BYTES });
|
||||||
|
}
|
||||||
|
if bytes.len() > MAX_SNAPSHOT_BYTES {
|
||||||
|
return Err(SyncClientError::SnapshotTooLarge {
|
||||||
|
bytes: bytes.len(),
|
||||||
|
limit: MAX_SNAPSHOT_BYTES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bytes);
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let payload_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
|
||||||
|
Ok(Self { bytes, payload_hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bytes(&self) -> &[u8] {
|
||||||
|
&self.bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn payload_hash(&self) -> &str {
|
||||||
|
&self.payload_hash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct SnapshotUploadRequest<'a> {
|
||||||
|
pub version: u32,
|
||||||
|
pub snapshot_id: &'a str,
|
||||||
|
pub region: &'a str,
|
||||||
|
pub payload_hash: &'a str,
|
||||||
|
pub schema_rev: u32,
|
||||||
|
pub logical_clock: u64,
|
||||||
|
pub data_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SnapshotUploadRequest<'a> {
|
||||||
|
pub fn new(
|
||||||
|
snapshot_id: &'a str,
|
||||||
|
region: &'a str,
|
||||||
|
schema_rev: u32,
|
||||||
|
logical_clock: u64,
|
||||||
|
payload: &'a SnapshotPayload,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
snapshot_id,
|
||||||
|
region,
|
||||||
|
payload_hash: payload.payload_hash(),
|
||||||
|
schema_rev,
|
||||||
|
logical_clock,
|
||||||
|
data_base64: encode_base64(payload.bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SnapshotDownload {
|
||||||
|
pub version: u32,
|
||||||
|
pub user_id: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub snapshot: SnapshotDocument,
|
||||||
|
pub data_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SnapshotDownload {
|
||||||
|
/// Decode + hash-verify the returned base64 payload against the
|
||||||
|
/// snapshot's advertised `payload_hash`. Mirrors the contract the
|
||||||
|
/// worker enforces on upload — we re-check on download so a
|
||||||
|
/// tampered storage layer doesn't silently desync the user.
|
||||||
|
pub fn payload(&self) -> Result<SnapshotPayload, SyncClientError> {
|
||||||
|
let bytes = decode_base64(&self.data_base64)
|
||||||
|
.map_err(|error| SyncClientError::SnapshotBase64(error.to_string()))?;
|
||||||
|
let payload = SnapshotPayload::new(bytes)?;
|
||||||
|
if payload.payload_hash() != self.snapshot.payload_hash {
|
||||||
|
return Err(SyncClientError::SnapshotBase64(
|
||||||
|
"downloaded snapshot hash does not match server-advertised hash".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SnapshotDocument {
|
||||||
|
pub snapshot_id: String,
|
||||||
|
pub r2_key: String,
|
||||||
|
pub payload_hash: String,
|
||||||
|
pub schema_rev: u32,
|
||||||
|
pub logical_clock: u64,
|
||||||
|
pub device_id: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub created_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
fn encode_base64(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
let len = chunk.len();
|
||||||
|
let b0 = chunk[0];
|
||||||
|
let b1 = if len > 1 { chunk[1] } else { 0 };
|
||||||
|
let b2 = if len > 2 { chunk[2] } else { 0 };
|
||||||
|
out.push(BASE64_CHARS[(b0 >> 2) as usize] as char);
|
||||||
|
out.push(BASE64_CHARS[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
|
||||||
|
out.push(if len > 1 {
|
||||||
|
BASE64_CHARS[(((b1 & 0b1111) << 2) | (b2 >> 6)) as usize] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
out.push(if len > 2 { BASE64_CHARS[(b2 & 0b111111) as usize] as char } else { '=' });
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Base64DecodeError(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for Base64DecodeError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_base64(value: &str) -> Result<Vec<u8>, Base64DecodeError> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.len().is_multiple_of(4) {
|
||||||
|
return Err(Base64DecodeError("input length is not a multiple of 4".to_string()));
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(trimmed.len() / 4 * 3);
|
||||||
|
let mut buffer = [0u32; 4];
|
||||||
|
let mut buffer_len = 0;
|
||||||
|
let mut padding = 0;
|
||||||
|
for byte in trimmed.bytes() {
|
||||||
|
let value = match byte {
|
||||||
|
b'A'..=b'Z' => u32::from(byte - b'A'),
|
||||||
|
b'a'..=b'z' => u32::from(byte - b'a') + 26,
|
||||||
|
b'0'..=b'9' => u32::from(byte - b'0') + 52,
|
||||||
|
b'+' => 62,
|
||||||
|
b'/' => 63,
|
||||||
|
b'=' => {
|
||||||
|
padding += 1;
|
||||||
|
if padding > 2 {
|
||||||
|
return Err(Base64DecodeError("excess padding".to_string()));
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
_ => return Err(Base64DecodeError(format!("unexpected byte {byte:#x}"))),
|
||||||
|
};
|
||||||
|
buffer[buffer_len] = value;
|
||||||
|
buffer_len += 1;
|
||||||
|
if buffer_len == 4 {
|
||||||
|
let combined = (buffer[0] << 18) | (buffer[1] << 12) | (buffer[2] << 6) | buffer[3];
|
||||||
|
out.push(((combined >> 16) & 0xFF) as u8);
|
||||||
|
if padding < 2 {
|
||||||
|
out.push(((combined >> 8) & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
if padding < 1 {
|
||||||
|
out.push((combined & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
buffer_len = 0;
|
||||||
|
padding = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buffer_len != 0 {
|
||||||
|
return Err(Base64DecodeError("trailing partial quartet".to_string()));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_rejects_empty_and_oversized() {
|
||||||
|
assert!(SnapshotPayload::new(vec![]).is_err());
|
||||||
|
let too_big = vec![0u8; MAX_SNAPSHOT_BYTES + 1];
|
||||||
|
assert!(SnapshotPayload::new(too_big).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_hash_matches_sha256() -> Result<(), SyncClientError> {
|
||||||
|
let payload = SnapshotPayload::new(b"ely".to_vec())?;
|
||||||
|
// Real SHA-256 of "ely" — verified at test time so a future
|
||||||
|
// hashing regression is caught immediately.
|
||||||
|
assert_eq!(
|
||||||
|
payload.payload_hash(),
|
||||||
|
"52fe440ae87b395cc46438ee9275fcadc0f98a048161345f53d6b0038976a6f5"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_round_trip() -> Result<(), Base64DecodeError> {
|
||||||
|
for sample in [b"".as_slice(), b"a", b"ab", b"abc", b"ely browser"] {
|
||||||
|
let encoded = encode_base64(sample);
|
||||||
|
let decoded = decode_base64(&encoded)?;
|
||||||
|
assert_eq!(decoded.as_slice(), sample);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user