feat(sync): round trip cloud snapshots
This commit is contained in:
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ed25519-dalek.workspace = true
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -106,6 +106,19 @@ impl SyncApiClient {
|
||||
read_json_response::<DeviceListResponse>(&endpoint, response)
|
||||
}
|
||||
|
||||
/// `GET /api/sync/status` — return the worker-side cursor,
|
||||
/// object, snapshot, and device summary for the authenticated
|
||||
/// approved device.
|
||||
pub fn sync_status(&self) -> Result<SyncStatusDocument, SyncClientError> {
|
||||
let endpoint = self.endpoint("/api/sync/status");
|
||||
let response = self
|
||||
.agent
|
||||
.get(&endpoint)
|
||||
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
||||
.call();
|
||||
read_json_response::<SyncStatusDocument>(&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.
|
||||
@@ -162,6 +175,55 @@ pub struct SnapshotUploadDocument {
|
||||
pub snapshot: crate::snapshot::SnapshotDocument,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct SyncStatusDocument {
|
||||
pub version: u32,
|
||||
pub user_id: String,
|
||||
pub device_id: String,
|
||||
pub cursor: SyncCursorStatusDocument,
|
||||
pub objects: Vec<SyncObjectStatusDocument>,
|
||||
pub snapshots: SyncSnapshotStatusDocument,
|
||||
pub devices: SyncDeviceStatusDocument,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct SyncCursorStatusDocument {
|
||||
pub latest_change_id: u64,
|
||||
pub total_changes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct SyncObjectStatusDocument {
|
||||
pub object_type: String,
|
||||
pub active_count: u64,
|
||||
pub deleted_count: u64,
|
||||
pub latest_logical_clock: u64,
|
||||
pub latest_updated_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct SyncSnapshotStatusDocument {
|
||||
pub total_snapshots: u64,
|
||||
pub latest: Option<SyncLatestSnapshotDocument>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct SyncLatestSnapshotDocument {
|
||||
pub snapshot_id: String,
|
||||
pub payload_hash: String,
|
||||
pub logical_clock: u64,
|
||||
pub device_id: String,
|
||||
pub size_bytes: u64,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct SyncDeviceStatusDocument {
|
||||
pub approved_count: u64,
|
||||
pub current_device_id: String,
|
||||
pub current_device_approved: bool,
|
||||
}
|
||||
|
||||
fn read_json_response<T: DeserializeOwned>(
|
||||
endpoint: &str,
|
||||
response: Result<ureq::Response, ureq::Error>,
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::{
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -51,10 +52,7 @@ impl DeviceIdentity {
|
||||
|
||||
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());
|
||||
let public_key = public_key_hex();
|
||||
Self { device_id, public_key, device_name: device_name.into(), platform: platform.into() }
|
||||
}
|
||||
|
||||
@@ -102,6 +100,18 @@ fn io_err(error: io::Error) -> SyncClientError {
|
||||
SyncClientError::TokenStorage(error.to_string())
|
||||
}
|
||||
|
||||
fn public_key_hex() -> String {
|
||||
let mut seed = [0_u8; 32];
|
||||
seed[..16].copy_from_slice(Uuid::now_v7().as_bytes());
|
||||
seed[16..].copy_from_slice(Uuid::now_v7().as_bytes());
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
hex_string(&signing_key.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
fn hex_string(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DeviceRegistration<'a> {
|
||||
pub device_id: &'a str,
|
||||
@@ -147,6 +157,8 @@ mod tests {
|
||||
let path = dir.join("device.json");
|
||||
let identity = DeviceIdentity::load_or_create(&path, "Test", "macos")?;
|
||||
identity.validate()?;
|
||||
assert_eq!(identity.public_key.len(), 64);
|
||||
assert!(identity.public_key.as_bytes().iter().all(u8::is_ascii_hexdigit));
|
||||
|
||||
let again = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?;
|
||||
assert_eq!(identity, again);
|
||||
|
||||
@@ -30,4 +30,10 @@ pub enum SyncClientError {
|
||||
|
||||
#[error("Snapshot base64 decode failed: {0}")]
|
||||
SnapshotBase64(String),
|
||||
|
||||
#[error("Snapshot schema is invalid: {0}")]
|
||||
SnapshotSchema(String),
|
||||
|
||||
#[error("Device {device_id} cannot sync with approval status {status}")]
|
||||
DeviceApprovalStatus { device_id: String, status: String },
|
||||
}
|
||||
|
||||
@@ -10,19 +10,11 @@
|
||||
//! - 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
|
||||
//! - Sync status (`GET /api/sync/status`), snapshot upload
|
||||
//! (`POST /api/sync/snapshot`), and snapshot 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;
|
||||
@@ -32,7 +24,7 @@ pub mod error;
|
||||
pub mod snapshot;
|
||||
|
||||
pub use auth::{BearerToken, BearerTokenStore};
|
||||
pub use client::{ApiClientConfig, SyncApiClient};
|
||||
pub use client::{ApiClientConfig, SyncApiClient, SyncLatestSnapshotDocument, SyncStatusDocument};
|
||||
pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration};
|
||||
pub use email_otp::{send_email_otp, verify_email_otp};
|
||||
pub use error::SyncClientError;
|
||||
|
||||
@@ -44,6 +44,10 @@ impl SnapshotPayload {
|
||||
pub fn payload_hash(&self) -> &str {
|
||||
&self.payload_hash
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> Vec<u8> {
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
||||
Reference in New Issue
Block a user