diff --git a/Cargo.lock b/Cargo.lock index d49bd5a..7312b40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2291,7 +2291,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "replicant" -version = "0.3.0" +version = "0.4.0" dependencies = [ "replicant-client", "replicant-core", @@ -2299,7 +2299,7 @@ dependencies = [ [[package]] name = "replicant-client" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "cbindgen", @@ -2336,7 +2336,7 @@ dependencies = [ [[package]] name = "replicant-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "argon2", "axum", @@ -2355,7 +2355,7 @@ dependencies = [ [[package]] name = "replicant-seed" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "clap 4.5.38", diff --git a/examples/cpp/callback_example.cpp b/examples/cpp/callback_example.cpp index fc6bb70..6e9400e 100644 --- a/examples/cpp/callback_example.cpp +++ b/examples/cpp/callback_example.cpp @@ -152,6 +152,7 @@ void sync_event_callback( // Error callback - receives error message void error_event_callback( EventType event_type, + int32_t error_code, const char* error, void* context) { diff --git a/replicant-client/Cargo.toml b/replicant-client/Cargo.toml index fa412dd..adf7f79 100644 --- a/replicant-client/Cargo.toml +++ b/replicant-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "replicant-client" -version = "0.3.0" +version = "0.4.0" edition = "2021" [dependencies] diff --git a/replicant-client/cbindgen.toml b/replicant-client/cbindgen.toml index 9bf2d4f..bccff11 100644 --- a/replicant-client/cbindgen.toml +++ b/replicant-client/cbindgen.toml @@ -16,6 +16,7 @@ include = [ "SyncResult", "Document", "EventType", + "ReplicantErrorCode", "DocumentEventCallback", "SyncEventCallback", "ErrorEventCallback", diff --git a/replicant-client/examples/cpp_lambda_callbacks.cpp b/replicant-client/examples/cpp_lambda_callbacks.cpp index 0956a6b..59e4ef6 100644 --- a/replicant-client/examples/cpp_lambda_callbacks.cpp +++ b/replicant-client/examples/cpp_lambda_callbacks.cpp @@ -108,7 +108,7 @@ void on_connection_event(EventType event_type, bool is_connected, /** * @brief Error event callback */ -void on_error_event(EventType event_type, const char* error_message, void* context) +void on_error_event(EventType event_type, int32_t error_code, const char* error_message, void* context) { auto* stats = static_cast(context); stats->errors++; diff --git a/replicant-client/examples/task_list_example.rs b/replicant-client/examples/task_list_example.rs index 4ee3da8..3eb4dbb 100644 --- a/replicant-client/examples/task_list_example.rs +++ b/replicant-client/examples/task_list_example.rs @@ -588,7 +588,7 @@ async fn main() -> Result<(), Box> { app_state.last_sync = Some(Instant::now()); app_state.needs_refresh = true; } - SyncEvent::SyncError { message } => { + SyncEvent::SyncError { message, .. } => { app_state.add_activity( format!("Sync error: {}", message), ActivityType::Error, diff --git a/replicant-client/examples/test_rust_callbacks.rs b/replicant-client/examples/test_rust_callbacks.rs index c35421f..c684e3e 100644 --- a/replicant-client/examples/test_rust_callbacks.rs +++ b/replicant-client/examples/test_rust_callbacks.rs @@ -86,7 +86,7 @@ async fn main() -> Result<(), Box> { SyncEvent::SyncCompleted { document_count } => { format!("✅ Sync completed: {} docs", document_count) } - SyncEvent::SyncError { message } => { + SyncEvent::SyncError { message, .. } => { format!("🚨 Sync error: {}", message) } SyncEvent::ConnectionLost { server_url } => { diff --git a/replicant-client/include/replicant.h b/replicant-client/include/replicant.h index f59ac7e..bcd624f 100644 --- a/replicant-client/include/replicant.h +++ b/replicant-client/include/replicant.h @@ -68,6 +68,81 @@ typedef enum ReplicantEventType { IdentityChanged = 10, } ReplicantEventType; +/** + * Structured error code carried by every `SyncError` event. + * + * The numeric values are STABLE and exported to C via cbindgen. They are + * banded by the action a consumer should take: + * + * - `0` — unknown / uncategorized. + * - `1xxx` — **credential rejected**: the stored credential is bad. The + * consumer should clear it and re-enroll. See [`is_credential_rejection`]. + * - `2xxx` — **transient**: retry later; NEVER clear credentials. This band + * includes the timestamp reasons (`2101`, `2102`), which are client/server + * clock skew — not a bad credential — and so must never trigger a clear. + * - `3xxx` — **protocol**: the exchange was malformed or violated the contract. + * - `4xxx` — **identity drift**: the local identity diverged from the account; + * refuse to sync, but do NOT clear credentials. + */ +enum ReplicantErrorCode +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + /** + * Unknown or uncategorized error. + */ + Unknown = 0, + /** + * The API key is not recognized by the server. + */ + InvalidApiKey = 1001, + /** + * The HMAC signature did not verify. + */ + InvalidSignature = 1002, + /** + * The credential authenticated but is not bound to an enrolled user. + */ + CredentialNotEnrolled = 1003, + /** + * The socket/transport failed to connect. + */ + ConnectionFailed = 2001, + /** + * A join or call timed out. + */ + Timeout = 2002, + /** + * The signed timestamp was outside the server's acceptance window + * (client/server clock skew, not a bad credential). + */ + TimestampExpired = 2101, + /** + * The timestamp field was malformed or unparseable (treated as clock skew). + */ + InvalidTimestamp = 2102, + /** + * A required join parameter was missing. + */ + MissingParams = 3001, + /** + * The join topic's user id did not match the credential's user. + */ + TopicUserMismatch = 3002, + /** + * Generic malformed or unexpected server reply. + */ + ProtocolError = 3003, + /** + * The server-reported user id diverged from the local identity. + */ + IdentityDrift = 4001, +}; +#ifndef __cplusplus +typedef int32_t ReplicantErrorCode; +#endif // __cplusplus + /** * Result codes for C API functions */ @@ -124,10 +199,14 @@ typedef void (*SyncEventCallback)(enum ReplicantEventType event_type, * * # Parameters * * `event_type` - Always SyncError + * * `error_code` - Stable `ReplicantErrorCode` value; use + * `replicant_error_is_credential_rejection` to decide whether to clear the + * stored credential * * `error` - Error message (always non-null) * * `context` - User-defined context pointer */ typedef void (*ErrorEventCallback)(enum ReplicantEventType event_type, + int32_t error_code, const char *error, void *context); @@ -191,6 +270,18 @@ typedef struct Document { extern "C" { #endif // __cplusplus +/** + * Band check exposed over FFI: `true` iff `code` is a credential rejection. + * + * Bindings should treat a `true` result as "clear the stored credential and + * re-enroll". Implemented once here so consumers do not re-implement the band + * logic against the raw numeric values. + * + * # Safety + * This function is pure and takes the code by value; it is always safe to call. + */ +bool replicant_error_is_credential_rejection(int32_t code); + /** * Create a new sync engine instance * @@ -613,7 +704,8 @@ enum ReplicantSyncResult replicant_rebuild_search_index(struct Replicant *engine /** * Requests an enrollment token be emailed to `email`. Standalone HTTP call - * (no engine handle); spins a short-lived runtime to drive the async request. + * (no engine handle); runs on a dedicated thread with its own short-lived + * runtime so this is safe to call even from inside an async runtime context. * * # Safety * `base_url` and `email` must be valid, non-null C strings. diff --git a/replicant-client/src/client.rs b/replicant-client/src/client.rs index 87b8a7f..ff71ab6 100644 --- a/replicant-client/src/client.rs +++ b/replicant-client/src/client.rs @@ -1,4 +1,7 @@ -use crate::{database::ClientDatabase, events::EventDispatcher, websocket::WebSocketClient}; +use crate::{ + database::ClientDatabase, error_code::ReplicantErrorCode, events::EventDispatcher, + websocket::WebSocketClient, +}; use replicant_core::{ errors::ClientError, models::{Document, SyncStatus}, @@ -1364,10 +1367,10 @@ impl Client { error.as_deref().unwrap_or("unknown error") ); // Could emit an error event here - event_dispatcher.emit_sync_error(&format!( - "Create failed: {}", - error.as_deref().unwrap_or("unknown") - )); + event_dispatcher.emit_sync_error( + ReplicantErrorCode::Unknown, + &format!("Create failed: {}", error.as_deref().unwrap_or("unknown")), + ); } } @@ -1408,10 +1411,10 @@ impl Client { document_id, error.as_deref().unwrap_or("unknown error") ); - event_dispatcher.emit_sync_error(&format!( - "Update failed: {}", - error.as_deref().unwrap_or("unknown") - )); + event_dispatcher.emit_sync_error( + ReplicantErrorCode::Unknown, + &format!("Update failed: {}", error.as_deref().unwrap_or("unknown")), + ); } } @@ -1436,10 +1439,10 @@ impl Client { document_id, error.as_deref().unwrap_or("unknown error") ); - event_dispatcher.emit_sync_error(&format!( - "Delete failed: {}", - error.as_deref().unwrap_or("unknown") - )); + event_dispatcher.emit_sync_error( + ReplicantErrorCode::Unknown, + &format!("Delete failed: {}", error.as_deref().unwrap_or("unknown")), + ); } } diff --git a/replicant-client/src/error_code.rs b/replicant-client/src/error_code.rs new file mode 100644 index 0000000..7a7fe58 --- /dev/null +++ b/replicant-client/src/error_code.rs @@ -0,0 +1,194 @@ +//! Stable, structured error codes carried alongside `SyncError` events. +//! +//! Consumers must be able to react to a sync error by the *action* it calls for +//! (clear the credential? retry? refuse to sync?) without substring-matching a +//! free-form message. Every `SyncError` event carries a [`ReplicantErrorCode`] +//! whose numeric value is stable and exported to C. + +/// Structured error code carried by every `SyncError` event. +/// +/// The numeric values are STABLE and exported to C via cbindgen. They are +/// banded by the action a consumer should take: +/// +/// - `0` — unknown / uncategorized. +/// - `1xxx` — **credential rejected**: the stored credential is bad. The +/// consumer should clear it and re-enroll. See [`is_credential_rejection`]. +/// - `2xxx` — **transient**: retry later; NEVER clear credentials. This band +/// includes the timestamp reasons (`2101`, `2102`), which are client/server +/// clock skew — not a bad credential — and so must never trigger a clear. +/// - `3xxx` — **protocol**: the exchange was malformed or violated the contract. +/// - `4xxx` — **identity drift**: the local identity diverged from the account; +/// refuse to sync, but do NOT clear credentials. +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplicantErrorCode { + /// Unknown or uncategorized error. + Unknown = 0, + + // 1xxx — credential rejected (clear the stored credential and re-enroll) + /// The API key is not recognized by the server. + InvalidApiKey = 1001, + /// The HMAC signature did not verify. + InvalidSignature = 1002, + /// The credential authenticated but is not bound to an enrolled user. + CredentialNotEnrolled = 1003, + + // 2xxx — transient (retry; never clear credentials) + /// The socket/transport failed to connect. + ConnectionFailed = 2001, + /// A join or call timed out. + Timeout = 2002, + /// The signed timestamp was outside the server's acceptance window + /// (client/server clock skew, not a bad credential). + TimestampExpired = 2101, + /// The timestamp field was malformed or unparseable (treated as clock skew). + InvalidTimestamp = 2102, + + // 3xxx — protocol + /// A required join parameter was missing. + MissingParams = 3001, + /// The join topic's user id did not match the credential's user. + TopicUserMismatch = 3002, + /// Generic malformed or unexpected server reply. + ProtocolError = 3003, + + // 4xxx — identity drift (refuse to sync; do NOT clear credentials) + /// The server-reported user id diverged from the local identity. + IdentityDrift = 4001, +} + +/// True iff `code` is in the credential-rejection band (`1xxx`). +/// +/// A `true` result means the consumer should clear the stored credential and +/// re-enroll. This is the single source of truth for the band check so bindings +/// do not re-derive the range. +pub fn is_credential_rejection(code: ReplicantErrorCode) -> bool { + (1000..2000).contains(&(code as i32)) +} + +/// Map a server join-rejection reason string to its [`ReplicantErrorCode`]. +/// +/// The reasons are the atoms the phoenix server sends in `{:error, %{reason: +/// ""}}` (see `replicant_server` `Sync.Channel`/`Auth`). A reason that is +/// present but not recognized is treated as a protocol contract mismatch +/// ([`ReplicantErrorCode::ProtocolError`]); the *absent*-reason case is handled +/// by the caller ([`crate::websocket::error_code_for_join_reject`]). +pub fn error_code_for_reason(reason: &str) -> ReplicantErrorCode { + match reason { + "invalid_api_key" => ReplicantErrorCode::InvalidApiKey, + "invalid_signature" => ReplicantErrorCode::InvalidSignature, + "credential_not_enrolled" => ReplicantErrorCode::CredentialNotEnrolled, + "timestamp_expired" => ReplicantErrorCode::TimestampExpired, + "invalid_timestamp" => ReplicantErrorCode::InvalidTimestamp, + "missing_params" => ReplicantErrorCode::MissingParams, + "topic_user_mismatch" => ReplicantErrorCode::TopicUserMismatch, + "invalid_topic" => ReplicantErrorCode::ProtocolError, + _ => ReplicantErrorCode::ProtocolError, + } +} + +/// Band check exposed over FFI: `true` iff `code` is a credential rejection. +/// +/// Bindings should treat a `true` result as "clear the stored credential and +/// re-enroll". Implemented once here so consumers do not re-implement the band +/// logic against the raw numeric values. +/// +/// # Safety +/// This function is pure and takes the code by value; it is always safe to call. +#[no_mangle] +pub extern "C" fn replicant_error_is_credential_rejection(code: i32) -> bool { + (1000..2000).contains(&code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_server_reason_maps_to_its_code() { + assert_eq!( + error_code_for_reason("invalid_api_key"), + ReplicantErrorCode::InvalidApiKey + ); + assert_eq!( + error_code_for_reason("invalid_signature"), + ReplicantErrorCode::InvalidSignature + ); + assert_eq!( + error_code_for_reason("credential_not_enrolled"), + ReplicantErrorCode::CredentialNotEnrolled + ); + assert_eq!( + error_code_for_reason("timestamp_expired"), + ReplicantErrorCode::TimestampExpired + ); + assert_eq!( + error_code_for_reason("invalid_timestamp"), + ReplicantErrorCode::InvalidTimestamp + ); + assert_eq!( + error_code_for_reason("missing_params"), + ReplicantErrorCode::MissingParams + ); + assert_eq!( + error_code_for_reason("topic_user_mismatch"), + ReplicantErrorCode::TopicUserMismatch + ); + assert_eq!( + error_code_for_reason("invalid_topic"), + ReplicantErrorCode::ProtocolError + ); + } + + #[test] + fn unrecognized_reason_is_protocol_error() { + assert_eq!( + error_code_for_reason("something_new"), + ReplicantErrorCode::ProtocolError + ); + } + + #[test] + fn credential_rejection_band_is_1xxx_only() { + // 1xxx band: true + assert!(is_credential_rejection(ReplicantErrorCode::InvalidApiKey)); + assert!(is_credential_rejection( + ReplicantErrorCode::InvalidSignature + )); + assert!(is_credential_rejection( + ReplicantErrorCode::CredentialNotEnrolled + )); + + // every other band: false + assert!(!is_credential_rejection(ReplicantErrorCode::Unknown)); + assert!(!is_credential_rejection( + ReplicantErrorCode::ConnectionFailed + )); + assert!(!is_credential_rejection(ReplicantErrorCode::Timeout)); + assert!(!is_credential_rejection( + ReplicantErrorCode::TimestampExpired + )); + assert!(!is_credential_rejection( + ReplicantErrorCode::InvalidTimestamp + )); + assert!(!is_credential_rejection(ReplicantErrorCode::MissingParams)); + assert!(!is_credential_rejection( + ReplicantErrorCode::TopicUserMismatch + )); + assert!(!is_credential_rejection(ReplicantErrorCode::ProtocolError)); + assert!(!is_credential_rejection(ReplicantErrorCode::IdentityDrift)); + } + + #[test] + fn ffi_band_check_matches_rust_helper() { + assert!(replicant_error_is_credential_rejection( + ReplicantErrorCode::CredentialNotEnrolled as i32 + )); + assert!(!replicant_error_is_credential_rejection( + ReplicantErrorCode::Timeout as i32 + )); + assert!(!replicant_error_is_credential_rejection( + ReplicantErrorCode::IdentityDrift as i32 + )); + } +} diff --git a/replicant-client/src/events.rs b/replicant-client/src/events.rs index 2dc4c8c..2910227 100644 --- a/replicant-client/src/events.rs +++ b/replicant-client/src/events.rs @@ -31,6 +31,7 @@ //! //! This design eliminates the need for complex synchronization in user code. +use crate::error_code::ReplicantErrorCode; use replicant_core::{errors::ClientError, SyncResult}; use std::ffi::{c_char, c_void, CString}; use std::sync::{mpsc, Mutex}; @@ -120,7 +121,10 @@ pub enum SyncEvent { /// Synchronization completed SyncCompleted { document_count: u64 }, /// A sync error occurred - SyncError { message: String }, + SyncError { + code: ReplicantErrorCode, + message: String, + }, /// A conflict was detected ConflictDetected { document_id: String, @@ -200,6 +204,7 @@ impl SyncEvent { document_count: event.numeric_data, }, EventType::SyncError => SyncEvent::SyncError { + code: event.error_code, message: event .error .clone() @@ -267,10 +272,17 @@ pub type SyncEventCallback = /// /// # Parameters /// * `event_type` - Always SyncError +/// * `error_code` - Stable `ReplicantErrorCode` value; use +/// `replicant_error_is_credential_rejection` to decide whether to clear the +/// stored credential /// * `error` - Error message (always non-null) /// * `context` - User-defined context pointer -pub type ErrorEventCallback = - extern "C" fn(event_type: EventType, error: *const c_char, context: *mut c_void); +pub type ErrorEventCallback = extern "C" fn( + event_type: EventType, + error_code: i32, + error: *const c_char, + context: *mut c_void, +); /// Connection event callback for ConnectionLost, ConnectionAttempted, ConnectionSucceeded /// @@ -383,6 +395,7 @@ pub struct QueuedEvent { title: Option, content: Option, error: Option, + error_code: ReplicantErrorCode, numeric_data: u64, boolean_data: bool, user_id: Option, @@ -801,19 +814,27 @@ impl EventDispatcher { ); } - pub fn emit_sync_error(&self, error_message: &str) { - self.queue_event( - EventType::SyncError, - None, - None, - None, - Some(error_message), - 0, - false, - None, - None, - None, - ); + pub fn emit_sync_error(&self, code: ReplicantErrorCode, error_message: &str) { + // SyncError is the only event that carries a structured code, so it + // bypasses the shared `queue_event` (whose other callers have no code) + // and builds the queued event directly. + let queued_event = QueuedEvent { + event_type: EventType::SyncError, + document_id: None, + title: None, + content: None, + error: Some(error_message.to_string()), + error_code: code, + numeric_data: 0, + boolean_data: false, + user_id: None, + author_name: None, + visibility: None, + }; + + if self.event_sender.send(queued_event).is_err() { + tracing::error!("Failed to queue event - receiver may have been dropped"); + } } pub fn emit_conflict_detected(&self, document_id: &Uuid) { @@ -913,6 +934,7 @@ impl EventDispatcher { title: title.map(|t| t.to_string()), content: content.map(|c| serde_json::to_string(c).unwrap_or_else(|_| "{}".to_string())), error: error.map(|e| e.to_string()), + error_code: ReplicantErrorCode::Unknown, numeric_data, boolean_data, user_id, @@ -1135,8 +1157,14 @@ impl EventDispatcher { EventType::SyncError => { let error_ptr = error_cstr.unwrap_or(std::ptr::null()); + let error_code = queued_event.error_code as i32; for entry in error_callbacks.iter() { - (entry.callback)(queued_event.event_type, error_ptr, entry.context); + (entry.callback)( + queued_event.event_type, + error_code, + error_ptr, + entry.context, + ); } } @@ -1494,6 +1522,7 @@ mod tests { extern "C" fn error_callback( _event_type: EventType, + _error_code: i32, _error: *const c_char, context: *mut c_void, ) { @@ -1508,7 +1537,7 @@ mod tests { ) .unwrap(); - dispatcher.emit_sync_error("Test error"); + dispatcher.emit_sync_error(ReplicantErrorCode::Unknown, "Test error"); let processed = dispatcher.process_events().unwrap(); assert_eq!(processed, 1); diff --git a/replicant-client/src/ffi.rs b/replicant-client/src/ffi.rs index 0a3e593..05abc3a 100644 --- a/replicant-client/src/ffi.rs +++ b/replicant-client/src/ffi.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use tokio::runtime::Runtime; use uuid::Uuid; +use crate::error_code::ReplicantErrorCode; use crate::events::{ ConflictEventCallback, ConnectionEventCallback, DocumentEventCallback, ErrorEventCallback, EventDispatcher, EventType, IdentityEventCallback, SyncEventCallback, @@ -177,7 +178,10 @@ pub unsafe extern "C" fn replicant_create( event_dispatcher_clone.emit_sync_completed(0); } Err(e) => { - event_dispatcher_clone.emit_sync_error(&format!("Background init failed: {}", e)); + event_dispatcher_clone.emit_sync_error( + ReplicantErrorCode::Unknown, + &format!("Background init failed: {}", e), + ); } } }); diff --git a/replicant-client/src/ffi_test.rs b/replicant-client/src/ffi_test.rs index 5de069f..b316d09 100644 --- a/replicant-client/src/ffi_test.rs +++ b/replicant-client/src/ffi_test.rs @@ -3,6 +3,7 @@ //! This module provides test-only C-compatible functions for development and testing. //! These functions are only available in debug builds. +use crate::error_code::ReplicantErrorCode; use crate::ffi::{Replicant, SyncResult}; use uuid::Uuid; @@ -62,9 +63,10 @@ pub unsafe extern "C" fn replicant_emit_test_event( } 3 => engine.event_dispatcher.emit_sync_started(), 4 => engine.event_dispatcher.emit_sync_completed(5), - 5 => engine - .event_dispatcher - .emit_sync_error("Test error message from replicant_emit_test_event"), + 5 => engine.event_dispatcher.emit_sync_error( + ReplicantErrorCode::Unknown, + "Test error message from replicant_emit_test_event", + ), 6 => { let test_id = Uuid::new_v4(); engine.event_dispatcher.emit_conflict_detected(&test_id); diff --git a/replicant-client/src/lib.rs b/replicant-client/src/lib.rs index 4139445..cbf3044 100644 --- a/replicant-client/src/lib.rs +++ b/replicant-client/src/lib.rs @@ -1,6 +1,7 @@ pub mod client; pub mod database; pub mod enrollment; +pub mod error_code; pub mod events; pub mod offline_queue; pub mod queries; @@ -16,7 +17,8 @@ pub mod ffi_test; pub use client::Client; pub use database::ClientDatabase; -pub use websocket::WebSocketClient; +pub use error_code::{is_credential_rejection, ReplicantErrorCode}; +pub use websocket::{error_code_for_join_reject, WebSocketClient}; #[cfg(test)] mod tests { diff --git a/replicant-client/src/websocket.rs b/replicant-client/src/websocket.rs index e0dcde1..7d8412b 100644 --- a/replicant-client/src/websocket.rs +++ b/replicant-client/src/websocket.rs @@ -1,7 +1,8 @@ +use crate::error_code::{error_code_for_reason, ReplicantErrorCode}; use crate::events::EventDispatcher; use hmac::{Hmac, Mac}; use phoenix_channels_client::{ - Channel, ChannelStatus, Event, Payload, Socket, StatusesError, Topic, + Channel, ChannelJoinError, ChannelStatus, Event, Payload, Socket, StatusesError, Topic, }; use replicant_core::{ errors::ClientError, @@ -83,7 +84,10 @@ impl WebSocketClient { socket.connect(CONNECT_TIMEOUT).await.map_err(|e| { if let Some(ref d) = event_dispatcher { - d.emit_sync_error(&format!("Connection failed: {:?}", e)); + d.emit_sync_error( + ReplicantErrorCode::ConnectionFailed, + &format!("Connection failed: {:?}", e), + ); } ws_err(format!("Connect failed: {:?}", e)) })?; @@ -105,7 +109,10 @@ impl WebSocketClient { let join_reply = channel.join(JOIN_TIMEOUT).await.map_err(|e| { if let Some(ref d) = event_dispatcher { - d.emit_sync_error(&format!("Join failed: {:?}", e)); + d.emit_sync_error( + error_code_for_join_reject(&e), + &format!("Join failed: {:?}", e), + ); } ws_err(format!("Join failed: {:?}", e)) })?; @@ -116,7 +123,7 @@ impl WebSocketClient { let reply_value = payload_to_value(&join_reply).unwrap_or_else(|| json!({})); if let Err(msg) = Self::verify_reply_identity(user_id, &reply_value) { if let Some(ref d) = event_dispatcher { - d.emit_sync_error(&msg); + d.emit_sync_error(ReplicantErrorCode::IdentityDrift, &msg); } let _ = channel.leave().await; return Err(ws_err(msg)); @@ -133,7 +140,10 @@ impl WebSocketClient { public_channel.join(JOIN_TIMEOUT).await.map_err(|e| { if let Some(ref d) = event_dispatcher { - d.emit_sync_error(&format!("Public channel join failed: {:?}", e)); + d.emit_sync_error( + error_code_for_join_reject(&e), + &format!("Public channel join failed: {:?}", e), + ); } ws_err(format!("Public channel join failed: {:?}", e)) })?; @@ -561,6 +571,26 @@ fn payload_to_value(p: &Payload) -> Option { } } +/// Derive a structured [`ReplicantErrorCode`] from a phoenix channel-join error. +/// +/// A server rejection carries a JSON payload `{"reason": ""}` (see +/// `replicant_server` `Sync.Channel`); the reason is mapped through +/// [`error_code_for_reason`]. A rejection with no `reason` field is `Unknown`. +/// Join timeouts map to [`ReplicantErrorCode::Timeout`] and every other +/// transport/socket failure to [`ReplicantErrorCode::ConnectionFailed`]. +pub fn error_code_for_join_reject(err: &ChannelJoinError) -> ReplicantErrorCode { + match err { + ChannelJoinError::Rejected { rejection } => payload_to_value(rejection) + .as_ref() + .and_then(|v| v.get("reason")) + .and_then(|r| r.as_str()) + .map(error_code_for_reason) + .unwrap_or(ReplicantErrorCode::Unknown), + ChannelJoinError::Timeout => ReplicantErrorCode::Timeout, + _ => ReplicantErrorCode::ConnectionFailed, + } +} + fn json_to_document(j: &Value) -> Option { // The server always carries ownership in the sync envelope: a string // user_id (owned doc) or null (public doc). A payload missing the key diff --git a/replicant-client/tests/ffi_integration_tests.rs b/replicant-client/tests/ffi_integration_tests.rs index 85c0fb3..1f37bd9 100644 --- a/replicant-client/tests/ffi_integration_tests.rs +++ b/replicant-client/tests/ffi_integration_tests.rs @@ -153,6 +153,7 @@ extern "C" fn sync_capture_callback( extern "C" fn error_capture_callback( _event_type: EventType, + _error_code: i32, error: *const c_char, context: *mut c_void, ) { diff --git a/replicant-client/tests/phoenix_integration/credential_enrollment_test.rs b/replicant-client/tests/phoenix_integration/credential_enrollment_test.rs index b954e75..eb5a0a4 100644 --- a/replicant-client/tests/phoenix_integration/credential_enrollment_test.rs +++ b/replicant-client/tests/phoenix_integration/credential_enrollment_test.rs @@ -10,7 +10,8 @@ //! Gated behind `RUN_INTEGRATION_TESTS`. The harness seeds the legacy credential //! and exports `REPLICANT_LEGACY_API_KEY` / `REPLICANT_LEGACY_API_SECRET`. -use super::{serial, skip_if_no_server, TestClient, TEST_EMAIL}; +use super::{raw_user_join_error, serial, skip_if_no_server, TestClient, TEST_EMAIL}; +use replicant_client::{error_code_for_join_reject, ReplicantErrorCode}; fn legacy_credentials() -> Option<(String, String)> { let key = std::env::var("REPLICANT_LEGACY_API_KEY").ok()?; @@ -43,4 +44,17 @@ async fn test_unenrolled_credential_rejected_at_join() { "expected credential_not_enrolled rejection, got: {}", err ); + + // Assert the STRUCTURED code the client derives from the real rejection + // payload, exercising the production mapping (not just the message string). + let join_error = raw_user_join_error(TEST_EMAIL, &legacy_key, &legacy_secret) + .await + .expect("unenrolled credential must produce a join error"); + assert_eq!( + error_code_for_join_reject(&join_error), + ReplicantErrorCode::CredentialNotEnrolled, + "expected CredentialNotEnrolled (1003), got: {:?} from {:?}", + error_code_for_join_reject(&join_error), + join_error + ); } diff --git a/replicant-client/tests/phoenix_integration/mod.rs b/replicant-client/tests/phoenix_integration/mod.rs index 6b6ef56..63265cf 100644 --- a/replicant-client/tests/phoenix_integration/mod.rs +++ b/replicant-client/tests/phoenix_integration/mod.rs @@ -30,7 +30,7 @@ mod multi_client_test; pub use serial_test::serial; use hmac::{Hmac, Mac}; -use phoenix_channels_client::{Channel, Event, Payload, Socket, Topic}; +use phoenix_channels_client::{Channel, ChannelJoinError, Event, Payload, Socket, Topic}; use replicant_core::models::Document; use serde_json::{json, Value}; use sha2::Sha256; @@ -258,6 +258,42 @@ impl TestClient { } } +/// Attempt only the per-user channel join and surface the RAW phoenix +/// `ChannelJoinError`, so tests can feed it to the production +/// `error_code_for_join_reject` mapping and assert the derived code (rather than +/// substring-matching the message). Returns `None` if the join unexpectedly +/// succeeded or the socket could not be set up. +pub async fn raw_user_join_error( + email: &str, + api_key: &str, + api_secret: &str, +) -> Option { + let url = Url::parse(&server_url()).ok()?; + let user_id = test_user_id(); + + let socket = Socket::spawn(url, None, None).await.ok()?; + socket.connect(Duration::from_secs(10)).await.ok()?; + + let timestamp = chrono::Utc::now().timestamp(); + let signature = create_hmac_signature(api_secret, timestamp, email, api_key); + let join_payload = json!({ + "email": email, + "api_key": api_key, + "signature": signature, + "timestamp": timestamp + }); + + let channel = socket + .channel( + Topic::from_string(format!("sync:user:{}", user_id)), + Some(to_payload(&join_payload).ok()?), + ) + .await + .ok()?; + + channel.join(Duration::from_secs(10)).await.err() +} + fn create_hmac_signature(secret: &str, timestamp: i64, email: &str, api_key: &str) -> String { let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key size"); mac.update(format!("{}.{}.{}.{}", timestamp, email, api_key, "").as_bytes()); diff --git a/replicant-core/Cargo.toml b/replicant-core/Cargo.toml index 7efe55d..dee4250 100644 --- a/replicant-core/Cargo.toml +++ b/replicant-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "replicant-core" -version = "0.3.0" +version = "0.4.0" edition = "2021" [features] diff --git a/replicant-seed/Cargo.toml b/replicant-seed/Cargo.toml index 5bce950..0d8d237 100644 --- a/replicant-seed/Cargo.toml +++ b/replicant-seed/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "replicant-seed" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Seed a Replicant server with a directory of JSON documents" diff --git a/replicant/Cargo.toml b/replicant/Cargo.toml index adc740d..13696fd 100644 --- a/replicant/Cargo.toml +++ b/replicant/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "replicant" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Offline-first document synchronization library" diff --git a/wrappers/juce/replicant/replicant.cpp b/wrappers/juce/replicant/replicant.cpp index b192011..c6383c0 100644 --- a/wrappers/juce/replicant/replicant.cpp +++ b/wrappers/juce/replicant/replicant.cpp @@ -82,11 +82,12 @@ void Replicant::connectionCallback(EventType eventType, bool /*isConnected*/, self->onConnectionChanged(eventType == ConnectionSucceeded); } -void Replicant::errorCallback(EventType /*eventType*/, const char* errorMessage, void* context) +void Replicant::errorCallback(EventType /*eventType*/, int32_t errorCode, + const char* errorMessage, void* context) { auto* self = static_cast(context); if (self->onSyncError && errorMessage) - self->onSyncError(errorMessage); + self->onSyncError(errorCode, errorMessage); } //============================================================================== diff --git a/wrappers/juce/replicant/replicant.h b/wrappers/juce/replicant/replicant.h index 18705c7..01c89ed 100644 --- a/wrappers/juce/replicant/replicant.h +++ b/wrappers/juce/replicant/replicant.h @@ -174,8 +174,14 @@ class Replicant : private juce::Timer /** Called when the connection state changes. */ std::function onConnectionChanged; - /** Called when a sync error occurs. */ - std::function onSyncError; + /** Called when a sync error occurs. + + @param errorCode Stable ReplicantErrorCode value. Use + replicant_error_is_credential_rejection() to decide + whether to clear the stored credential and re-enroll. + @param message Human-readable error message. + */ + std::function onSyncError; private: void timerCallback() override; @@ -186,7 +192,8 @@ class Replicant : private juce::Timer const char* visibility, void* context); static void connectionCallback(EventType eventType, bool isConnected, uint32_t attemptNumber, void* context); - static void errorCallback(EventType eventType, const char* errorMessage, void* context); + static void errorCallback(EventType eventType, int32_t errorCode, + const char* errorMessage, void* context); replicant::Client client;