Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ hypr-api-nango = { workspace = true }
hypr-api-research = { workspace = true }
hypr-api-subscription = { workspace = true }
hypr-api-support = { workspace = true }
hypr-api-sync = { workspace = true }
hypr-llm-proxy = { workspace = true }
hypr-transcribe-proxy = { workspace = true }
owhisper-client = { workspace = true }
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ async fn app() -> Router {
auth::optional_auth,
));

let sync_config =
hypr_api_sync::SyncConfig::new(&env.supabase.supabase_url, &env.supabase.supabase_anon_key);
let sync_state = hypr_api_sync::AppState::new(sync_config);
Comment on lines +169 to +171
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 SyncConfig uses supabase_anon_key but the API typically needs service role key

At apps/api/src/main.rs:170, SyncConfig is constructed with supabase_anon_key. Looking at crates/api-sync/src/config.rs:11-17, the config stores supabase_anon_key. However, the server-side sync operations (creating vaults, managing files) will likely need the supabase_service_role_key for admin-level database operations, similar to how NangoConfig at apps/api/src/main.rs:78 uses supabase_service_role_key. This may need to be changed when the stub implementations are filled in.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

let auth_state_sync = AuthState::new(&env.supabase.supabase_url);
let sync_routes = Router::new()
.nest("/sync", hypr_api_sync::router(sync_state))
.route_layer(middleware::from_fn(auth::sentry_and_analytics))
.route_layer(middleware::from_fn_with_state(
auth_state_sync,
auth::require_auth,
));

Router::new()
.route("/health", axum::routing::get(version))
.route("/openapi.json", axum::routing::get(openapi_json))
Expand All @@ -174,6 +186,7 @@ async fn app() -> Router {
.merge(pro_routes)
.merge(integration_routes)
.merge(auth_routes)
.merge(sync_routes)
.layer(
CorsLayer::new()
.allow_origin(cors::Any)
Expand Down
3 changes: 3 additions & 0 deletions crates/api-sync/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ tracing = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }

chrono = { workspace = true, features = ["serde"] }
uuid = { workspace = true, features = ["serde", "v4"] }
1 change: 1 addition & 0 deletions crates/api-sync/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod config;
mod error;
mod routes;
mod state;
pub mod types;

pub use config::SyncConfig;
pub use error::{Result, SyncError};
Expand Down
27 changes: 27 additions & 0 deletions crates/api-sync/src/routes/blobs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
use axum::body::Bytes;
use axum::extract::Path;
use axum::http::StatusCode;
use uuid::Uuid;

use crate::error::Result;

/// POST /vaults/:vault_id/blobs -- upload blob
pub async fn upload_blob(Path(_vault_id): Path<Uuid>, _body: Bytes) -> Result<StatusCode> {
Err(crate::error::SyncError::Internal(
"not implemented".to_string(),
))
}

/// HEAD /vaults/:vault_id/blobs/:hash -- check blob existence
pub async fn check_blob(Path((_vault_id, _hash)): Path<(Uuid, String)>) -> Result<StatusCode> {
Err(crate::error::SyncError::Internal(
"not implemented".to_string(),
))
}

/// GET /vaults/:vault_id/blobs/:hash -- download blob
pub async fn download_blob(Path((_vault_id, _hash)): Path<(Uuid, String)>) -> Result<Bytes> {
Err(crate::error::SyncError::Internal(
"not implemented".to_string(),
))
}
19 changes: 18 additions & 1 deletion crates/api-sync/src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
mod blobs;
mod ops;
mod vaults;

use axum::Router;
use axum::routing::{get, head, post};
use utoipa::OpenApi;

use crate::state::AppState;
Expand All @@ -16,5 +21,17 @@ pub fn openapi() -> utoipa::openapi::OpenApi {
}

pub fn router(state: AppState) -> Router {
Router::new().with_state(state)
let vault_routes = Router::new()
.route("/", post(vaults::create_vault))
.route("/", get(vaults::list_vaults))
.route("/{vault_id}/devices", post(vaults::register_device))
.route("/{vault_id}/ops", post(ops::push_ops))
.route("/{vault_id}/ops", get(ops::pull_ops))
.route("/{vault_id}/blobs", post(blobs::upload_blob))
.route("/{vault_id}/blobs/{hash}", head(blobs::check_blob))
.route("/{vault_id}/blobs/{hash}", get(blobs::download_blob));

Router::new()
.nest("/vaults", vault_routes)
.with_state(state)
}
34 changes: 34 additions & 0 deletions crates/api-sync/src/routes/ops.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
use axum::Json;
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use serde::Deserialize;
use uuid::Uuid;

use crate::error::Result;
use crate::types::{PullOpsResponse, PushOpsRequest, PushOpsResponse};

#[derive(Deserialize)]
pub struct PullOpsQuery {
pub cursor: Option<i64>,

Check warning on line 12 in crates/api-sync/src/routes/ops.rs

View workflow job for this annotation

GitHub Actions / desktop_ci (linux-x86_64, depot-ubuntu-22.04-8)

fields `cursor` and `limit` are never read

Check warning on line 12 in crates/api-sync/src/routes/ops.rs

View workflow job for this annotation

GitHub Actions / desktop_ci (macos, depot-macos-15)

fields `cursor` and `limit` are never read

Check warning on line 12 in crates/api-sync/src/routes/ops.rs

View workflow job for this annotation

GitHub Actions / desktop_ci (linux-aarch64, depot-ubuntu-22.04-arm-8)

fields `cursor` and `limit` are never read
pub limit: Option<i64>,
}

/// POST /vaults/:vault_id/ops -- push operations
pub async fn push_ops(
Path(_vault_id): Path<Uuid>,
Json(_body): Json<PushOpsRequest>,
) -> Result<(StatusCode, Json<PushOpsResponse>)> {
Err(crate::error::SyncError::Internal(
"not implemented".to_string(),
))
}

/// GET /vaults/:vault_id/ops?cursor=N&limit=100 -- pull operations
pub async fn pull_ops(
Path(_vault_id): Path<Uuid>,
Query(_query): Query<PullOpsQuery>,
) -> Result<Json<PullOpsResponse>> {
Err(crate::error::SyncError::Internal(
"not implemented".to_string(),
))
}
36 changes: 36 additions & 0 deletions crates/api-sync/src/routes/vaults.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use axum::Json;
use axum::extract::Path;
use axum::http::StatusCode;
use uuid::Uuid;

use crate::error::Result;
use crate::types::{
CreateVaultRequest, CreateVaultResponse, ListVaultsResponse, RegisterDeviceRequest,
RegisterDeviceResponse,
};

/// POST /vaults -- create vault
pub async fn create_vault(
Json(_body): Json<CreateVaultRequest>,
) -> Result<(StatusCode, Json<CreateVaultResponse>)> {
Err(crate::error::SyncError::Internal(
"not implemented".to_string(),
))
}

/// GET /vaults -- list user's vaults
pub async fn list_vaults() -> Result<Json<ListVaultsResponse>> {
Err(crate::error::SyncError::Internal(
"not implemented".to_string(),
))
}

/// POST /vaults/:vault_id/devices -- register device
pub async fn register_device(
Path(_vault_id): Path<Uuid>,
Json(_body): Json<RegisterDeviceRequest>,
) -> Result<(StatusCode, Json<RegisterDeviceResponse>)> {
Err(crate::error::SyncError::Internal(
"not implemented".to_string(),
))
}
164 changes: 164 additions & 0 deletions crates/api-sync/src/types.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
use std::fmt;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

// --- Newtypes ---

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VaultId(pub Uuid);

impl fmt::Display for VaultId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileId(pub Uuid);

impl fmt::Display for FileId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DeviceId(pub Uuid);

impl fmt::Display for DeviceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BlobHash(pub String);

impl fmt::Display for BlobHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}

// --- OpType ---

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpType {
Create,
Modify,
Move,
Delete,
}

// --- OperationPayload ---

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OperationPayload {
/// Small content (< 256KB) -- stored inline in Postgres
Inline { content: Vec<u8> },
/// Large content -- stored in S3, referenced by hash
BlobRef { hash: BlobHash, size_bytes: u64 },
/// Move operation -- new path
MoveTo { new_path: String },
/// Delete -- tombstone, no content
Tombstone,
}

// --- Operation ---

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Operation {
pub id: Uuid,
pub vault_id: VaultId,
pub file_id: FileId,
pub author_user_id: Uuid,
pub author_device_id: DeviceId,
pub base_version: i64,
pub op_type: OpType,
pub payload: OperationPayload,
pub created_at: DateTime<Utc>,
/// Monotonic ordering for cursor-based pull
pub seq: i64,
}

// --- FileEntry ---

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub id: FileId,
pub vault_id: VaultId,
pub path: String,
pub version: i64,
pub content_hash: Option<BlobHash>,
pub is_deleted: bool,
}

// --- Request/Response types ---

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PushOperation {
pub file_id: FileId,
pub base_version: i64,
pub op_type: OpType,
pub payload: OperationPayload,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PushOpsRequest {
pub ops: Vec<PushOperation>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PushOpsResponse {
pub accepted: Vec<Uuid>,
pub rejected: Vec<RejectedOp>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RejectedOp {
pub file_id: FileId,
pub reason: String,
pub current_version: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullOpsResponse {
pub ops: Vec<Operation>,
pub next_cursor: Option<i64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateVaultRequest {
pub name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateVaultResponse {
pub vault_id: VaultId,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultInfo {
pub id: VaultId,
pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListVaultsResponse {
pub vaults: Vec<VaultInfo>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterDeviceRequest {
pub device_id: DeviceId,
pub name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterDeviceResponse {
pub device_id: DeviceId,
}
Loading