Skip to content

Commit be9ee9f

Browse files
committed
closed gaps in flutter and wasm for offline download
1 parent 5e0e282 commit be9ee9f

7 files changed

Lines changed: 907 additions & 36 deletions

File tree

crates/fula-client/Cargo.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ url = "2.5"
3535
base64 = { workspace = true }
3636
hex = { workspace = true }
3737
blake3 = { workspace = true }
38+
# Phase 3.3 — `derive_user_key_from_email` lives in `src/user_key.rs`
39+
# and is exposed on every target (wasm + native) so the wasm-bindgen
40+
# binding can compute the user_key without round-tripping through
41+
# `fula-crypto::derive_key_argon2id`. sha2 is a pure-Rust dep that
42+
# builds cleanly on wasm32; was previously gated to native-only when
43+
# the helper still lived inside the native-gated `registry_resolver`
44+
# module, but with the helper extracted we need cross-target.
45+
sha2 = { workspace = true }
3846
mime_guess = "2.0"
3947
tokio = { version = "1.42", default-features = false, features = ["sync"] }
4048
dashmap = { workspace = true }
@@ -52,8 +60,6 @@ dirs = "5"
5260
# Native-only — wasm builds skip the cache (no persistent storage there anyway).
5361
redb = { workspace = true }
5462
cid = { workspace = true }
55-
# CID verification on gateway-fetched bytes (Phase 2.3 of master-independent reads).
56-
sha2 = { workspace = true }
5763
# Mutex for per-gateway state in gateway_fetch (Phase 2.3).
5864
parking_lot = { workspace = true }
5965
# Phase 3.3 cold-start hybrid resolver — parses the master-published

crates/fula-client/src/lib.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ mod multipart;
5050
#[cfg(not(target_arch = "wasm32"))]
5151
mod registry_resolver;
5252
mod types;
53+
/// Phase 3.3 helper module — wasm-friendly userKey derivation
54+
/// extracted from `registry_resolver.rs` so the wasm-bindgen
55+
/// binding can expose it. Source-of-truth lives here; the
56+
/// resolver re-exports it on native.
57+
mod user_key;
5358
#[cfg(not(target_arch = "wasm32"))]
5459
mod orphan_queue;
5560
#[cfg(not(target_arch = "wasm32"))]
@@ -86,16 +91,24 @@ pub use types::*;
8691
/// callbacks without depending on internal module paths.
8792
pub use health_gate::{HealthCallback, MasterHealthEvent};
8893

94+
/// Phase 3.3 — `derive_user_key_from_email` available on EVERY
95+
/// target (wasm + native). Apps compute the userKey at sign-in
96+
/// time from the OAuth-provided email and stash it in
97+
/// `Config::users_index_user_key`. The same function is also
98+
/// re-exported via `registry_resolver` on native for backward
99+
/// compatibility with code that imports it from there.
100+
pub use user_key::derive_user_key_from_email;
101+
89102
/// Phase 3.3 — cold-start hybrid resolver public API. Native-only;
90103
/// the resolver itself is gated to `cfg(not(target_arch = "wasm32"))`.
91-
/// The free helper `derive_user_key_from_email` is also re-exported
92-
/// so JS / Flutter bindings can compute the user_key without holding
93-
/// a client.
104+
/// `derive_user_key_from_email` is re-exported above (cross-target);
105+
/// callers using the `fula_client::registry_resolver::derive_user_key_from_email`
106+
/// path also still resolve through the in-module `pub use`.
94107
#[cfg(not(target_arch = "wasm32"))]
95108
pub use registry_resolver::{
96109
decode_user_buckets_index, default_ipfs_gateway_urls, default_ipns_gateway_urls,
97-
derive_user_key_from_email, fetch_cid_via_gateways, BucketEntry, GlobalUsersIndex,
98-
ResolutionSource, ResolvedUsersIndex, ResolverConfig, UserBucketsIndex, UsersIndexResolver,
110+
fetch_cid_via_gateways, BucketEntry, GlobalUsersIndex, ResolutionSource,
111+
ResolvedUsersIndex, ResolverConfig, UserBucketsIndex, UsersIndexResolver,
99112
};
100113

101114
/// Process-wide count of WAL append failures (F11).

crates/fula-client/src/registry_resolver.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -217,15 +217,15 @@ impl ResolverConfig {
217217
/// stay in lockstep with the master's `hash_user_id`; the
218218
/// `derive_user_key_matches_master_state_rs_algorithm` test below
219219
/// reproduces the master algorithm step-by-step and asserts equality.
220-
pub fn derive_user_key_from_email(email: &str) -> String {
221-
use sha2::{Digest, Sha256};
222-
let user_id_digest = Sha256::digest(email.to_lowercase().as_bytes());
223-
let user_id_hex = hex::encode(user_id_digest);
224-
let mut hasher = blake3::Hasher::new();
225-
hasher.update(b"fula:user_id:");
226-
hasher.update(user_id_hex.as_bytes());
227-
hex::encode(&hasher.finalize().as_bytes()[..16])
228-
}
220+
///
221+
/// Source-of-truth lives in `crate::user_key` (extracted there so the
222+
/// wasm-bindgen binding can expose it — the `registry_resolver`
223+
/// module itself is gated to native targets). This re-export keeps
224+
/// the historical `fula_client::registry_resolver::derive_user_key_from_email`
225+
/// import path working for native callers AND lets the test module
226+
/// in this file (line 1485+) call the function via `use super::*;`.
227+
#[allow(unused_imports)]
228+
pub use crate::user_key::derive_user_key_from_email;
229229

230230
/// Default IPNS-aware gateway list. Excludes
231231
/// `trustless-gateway.link` (only serves `/ipfs/`, not `/ipns/`).

crates/fula-client/src/user_key.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//! Phase 3.3 — userKey derivation, available on every target.
2+
//!
3+
//! `derive_user_key_from_email` was originally inlined in
4+
//! `registry_resolver.rs`, but that module is gated to native via
5+
//! `#![cfg(not(target_arch = "wasm32"))]` because it depends on
6+
//! `reqwest`, `parking_lot`, and other crates that don't compile on
7+
//! wasm. The userKey computation itself is pure: just `sha2` +
8+
//! `blake3` + `hex` — all of which build cleanly on wasm32 (these
9+
//! are already transitive deps of the wasm SDK build).
10+
//!
11+
//! Extracting the helper here lets the FRB and wasm-bindgen
12+
//! bindings expose `derive_user_key_from_email` without having to
13+
//! re-implement the algorithm. Master and SDK both produce the
14+
//! same `userKey` for the same email, regardless of which target
15+
//! the SDK was built for.
16+
//!
17+
//! **Algorithm (must stay in lockstep with master's `state.rs::hash_user_id`):**
18+
//!
19+
//! ```text
20+
//! email_lower = email.to_lowercase()
21+
//! user_id_digest = sha256(email_lower.as_bytes())
22+
//! user_id_hex = hex(user_id_digest)
23+
//! domain_separated = "fula:user_id:" || user_id_hex
24+
//! user_key = hex( blake3(domain_separated)[..16] )
25+
//! ```
26+
//!
27+
//! Drift here vs. master = silent cold-start failure (master
28+
//! publishes under userKey A, SDK looks up userKey B). The
29+
//! `derive_user_key_matches_master_state_rs_algorithm` test in
30+
//! `registry_resolver.rs` reproduces master's algorithm step-by-step
31+
//! and asserts equality.
32+
33+
use sha2::{Digest, Sha256};
34+
35+
/// Compute the canonical fula `userKey` for cold-start config from a
36+
/// plaintext email. Returns 32 hex chars (16-byte BLAKE3 truncated digest).
37+
///
38+
/// Apps call this at sign-in time (the OAuth flow has plaintext email)
39+
/// and pass the returned string into `Config::users_index_user_key`.
40+
/// The SDK never persists or transmits the raw email.
41+
pub fn derive_user_key_from_email(email: &str) -> String {
42+
let user_id_digest = Sha256::digest(email.to_lowercase().as_bytes());
43+
let user_id_hex = hex::encode(user_id_digest);
44+
let mut hasher = blake3::Hasher::new();
45+
hasher.update(b"fula:user_id:");
46+
hasher.update(user_id_hex.as_bytes());
47+
hex::encode(&hasher.finalize().as_bytes()[..16])
48+
}

crates/fula-flutter/src/api/client.rs

Lines changed: 188 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,19 @@ use async_lock::RwLock;
1616
use crate::api::types::*;
1717

1818
/// Build the underlying `fula_client::Config` from the Dart-facing
19-
/// `FulaConfig`, plumbing every Phase 1.2 / 2.x field through. Used by
20-
/// `create_client`, `create_encrypted_client`, and
21-
/// `create_encrypted_client_with_pinning` to keep the three constructors
22-
/// in lockstep — adding a new field to FulaConfig only requires a
23-
/// change here.
24-
fn build_inner_config(config: &FulaConfig) -> fula_client::Config {
19+
/// `FulaConfig`, plumbing every Phase 1.2 / 2.x / 3.3 / 19 field
20+
/// through. Used by `create_client`, `create_encrypted_client`, and
21+
/// `create_encrypted_client_with_pinning` to keep the three
22+
/// constructors in lockstep — adding a new field to FulaConfig only
23+
/// requires a change here.
24+
///
25+
/// `dispatcher` is the per-handle dispatcher that the FRB layer
26+
/// always wires into `Config::health_callback` so apps can subscribe
27+
/// to `MasterHealthEvent` events via `subscribe_master_health_events`.
28+
fn build_inner_config(
29+
config: &FulaConfig,
30+
dispatcher: &Arc<HealthEventDispatcher>,
31+
) -> fula_client::Config {
2532
let mut inner = fula_client::Config::new(&config.endpoint)
2633
.with_timeout(Duration::from_secs(config.timeout_seconds));
2734

@@ -50,6 +57,45 @@ fn build_inner_config(config: &FulaConfig) -> fula_client::Config {
5057
inner.gateway_fallback_urls = config.gateway_fallback_urls.clone();
5158
inner.gateway_race_concurrency = config.gateway_race_concurrency as usize;
5259

60+
// Phase 3.3 — cold-start hybrid resolver. The resolver activates
61+
// iff all four required strings (rpc_url, anchor_address,
62+
// ipns_name, user_key) are non-empty AND the user_key is `Some`.
63+
// Empty strings collapse to "disabled" — same default behavior as
64+
// pre-Phase-3.3 builds.
65+
inner.users_index_chain_rpc_url = config.users_index_chain_rpc_url.clone();
66+
inner.users_index_anchor_address = config.users_index_anchor_address.clone();
67+
inner.users_index_ipns_name = config.users_index_ipns_name.clone();
68+
inner.users_index_user_key = if config.users_index_user_key.is_empty() {
69+
None
70+
} else {
71+
Some(config.users_index_user_key.clone())
72+
};
73+
inner.users_index_ipns_gateway_urls =
74+
config.users_index_ipns_gateway_urls.clone();
75+
inner.users_index_ipfs_gateway_urls =
76+
config.users_index_ipfs_gateway_urls.clone();
77+
78+
// Phase 19 — always wire a forwarding callback into the gate so
79+
// Dart-side subscribers can observe health transitions. The
80+
// dispatcher is per-handle, so events from this client never
81+
// leak to a different client's subscribers. Native-only — wasm
82+
// doesn't include the health-callback Arc in fula_client::Config
83+
// because `Arc<dyn Fn>` doesn't cross wasm-bindgen cleanly; the
84+
// wasm path surfaces via typed errors.
85+
#[cfg(not(target_arch = "wasm32"))]
86+
{
87+
let dispatcher = Arc::clone(dispatcher);
88+
let cb: fula_client::HealthCallback = Arc::new(move |ev| {
89+
dispatcher.dispatch(ev);
90+
});
91+
inner.health_callback = Some(cb);
92+
}
93+
// Suppress unused-variable warning on wasm where we don't read
94+
// `dispatcher` at config-build time (subscribers still register;
95+
// they just never receive events because no callback fires).
96+
#[cfg(target_arch = "wasm32")]
97+
let _ = dispatcher;
98+
5399
if let Some(token) = &config.access_token {
54100
inner = inner.with_token(token.clone());
55101
}
@@ -63,11 +109,13 @@ fn build_inner_config(config: &FulaConfig) -> fula_client::Config {
63109

64110
/// Create a new Fula client with the given configuration
65111
pub fn create_client(config: FulaConfig) -> anyhow::Result<FulaClientHandle> {
66-
let inner_config = build_inner_config(&config);
112+
let dispatcher = Arc::new(HealthEventDispatcher::new());
113+
let inner_config = build_inner_config(&config, &dispatcher);
67114
let client = fula_client::FulaClient::new(inner_config)?;
68115

69116
Ok(FulaClientHandle {
70117
inner: Arc::new(client),
118+
health_dispatcher: dispatcher,
71119
})
72120
}
73121

@@ -76,7 +124,8 @@ pub fn create_encrypted_client(
76124
config: FulaConfig,
77125
encryption: EncryptionConfig,
78126
) -> anyhow::Result<EncryptedClientHandle> {
79-
let inner_config = build_inner_config(&config);
127+
let dispatcher = Arc::new(HealthEventDispatcher::new());
128+
let inner_config = build_inner_config(&config, &dispatcher);
80129

81130
// Create encryption config
82131
let enc_config = if let Some(secret_key) = encryption.secret_key {
@@ -113,6 +162,7 @@ pub fn create_encrypted_client(
113162

114163
Ok(EncryptedClientHandle {
115164
inner: Arc::new(RwLock::new(client)),
165+
health_dispatcher: dispatcher,
116166
})
117167
}
118168

@@ -122,7 +172,8 @@ pub fn create_encrypted_client_with_pinning(
122172
encryption: EncryptionConfig,
123173
pinning: PinningConfig,
124174
) -> anyhow::Result<EncryptedClientHandle> {
125-
let inner_config = build_inner_config(&config);
175+
let dispatcher = Arc::new(HealthEventDispatcher::new());
176+
let inner_config = build_inner_config(&config, &dispatcher);
126177

127178
// Create encryption config
128179
let enc_config = if let Some(secret_key) = encryption.secret_key {
@@ -168,9 +219,137 @@ pub fn create_encrypted_client_with_pinning(
168219

169220
Ok(EncryptedClientHandle {
170221
inner: Arc::new(RwLock::new(client)),
222+
health_dispatcher: dispatcher,
171223
})
172224
}
173225

226+
// ============================================================================
227+
// Phase 3.3 — derive_user_key_from_email
228+
// ============================================================================
229+
230+
/// Compute the canonical fula `userKey` for cold-start config from a
231+
/// plaintext email. Mirrors `fula_client::derive_user_key_from_email`
232+
/// — same domain separator, same hash chain (sha256(lower(email))
233+
/// → BLAKE3("fula:user_id:" || _).bytes[..16] → hex-encode).
234+
///
235+
/// Apps call this once at sign-in (the OAuth flow has plaintext
236+
/// email), then set `FulaConfig::users_index_user_key` to the
237+
/// returned string. The SDK never sees the raw email.
238+
///
239+
/// Native-only — wasm32 surfaces this via the JS-side `deriveKey`
240+
/// helper because the cold-start resolver (Phase 3.3) itself isn't
241+
/// wired on wasm.
242+
#[cfg(not(target_arch = "wasm32"))]
243+
pub fn derive_user_key_from_email(email: String) -> String {
244+
fula_client::derive_user_key_from_email(&email)
245+
}
246+
247+
#[cfg(target_arch = "wasm32")]
248+
pub fn derive_user_key_from_email(_email: String) -> String {
249+
// The Rust cold-start resolver isn't wired on wasm32; expose
250+
// the function for API symmetry but emit an empty key so the
251+
// resolver self-disables (per build_inner_config: empty user_key
252+
// → users_index_user_key=None → resolver inactive).
253+
String::new()
254+
}
255+
256+
// ============================================================================
257+
// Phase 19 — health-event subscription
258+
// ============================================================================
259+
260+
/// Drain every `MasterHealthEvent` observed since the last call to
261+
/// this function. Returns events in the order they fired (oldest
262+
/// first). After draining the buffer is empty.
263+
///
264+
/// Apps poll this on a timer (or on UI rebuilds) and update their
265+
/// online/offline indicator. Internal buffer is bounded at 64
266+
/// entries — if an app falls so far behind that the buffer
267+
/// overflows, the oldest events are dropped first; the latest state
268+
/// is preserved. For latest-only consumers, see
269+
/// [`get_last_master_health_event`].
270+
///
271+
/// Events delivered:
272+
/// - `Online` — master went Up after being Down
273+
/// - `OfflineFallbackActive { reason }` — master went Down
274+
/// - `SeverelyDegraded { reason }` — both master AND cold-start
275+
/// channels (IPNS + chain) are unreachable; cold-start GETs
276+
/// will fail
277+
///
278+
/// Native-only at runtime: on wasm32 the function compiles for API
279+
/// symmetry but never returns events because the health-callback
280+
/// Arc isn't wired on wasm (`Arc<dyn Fn>` doesn't cross
281+
/// wasm-bindgen cleanly).
282+
pub fn poll_master_health_events(
283+
client: &FulaClientHandle,
284+
) -> Vec<MasterHealthEvent> {
285+
client.health_dispatcher.drain_events()
286+
}
287+
288+
/// Same as `poll_master_health_events` for an `EncryptedClientHandle`.
289+
/// Exposed separately because Dart-side the encrypted client has
290+
/// its own handle type and FRB doesn't auto-reflect "this method
291+
/// works on either handle".
292+
pub fn poll_master_health_events_encrypted(
293+
client: &EncryptedClientHandle,
294+
) -> Vec<MasterHealthEvent> {
295+
client.health_dispatcher.drain_events()
296+
}
297+
298+
/// Read the most recent `MasterHealthEvent` observed by the SDK
299+
/// without draining the buffer. Returns `None` if no transition has
300+
/// happened yet (master has been Up the whole session). Useful for
301+
/// apps that build UI state from a single field on mount.
302+
pub fn get_last_master_health_event(
303+
client: &FulaClientHandle,
304+
) -> Option<MasterHealthEvent> {
305+
client.health_dispatcher.last_event()
306+
}
307+
308+
/// Encrypted-client variant of `get_last_master_health_event`.
309+
pub fn get_last_master_health_event_encrypted(
310+
client: &EncryptedClientHandle,
311+
) -> Option<MasterHealthEvent> {
312+
client.health_dispatcher.last_event()
313+
}
314+
315+
// ============================================================================
316+
// Phase 19 — get_object_with_offline_fallback
317+
// ============================================================================
318+
319+
/// Phase 19 GET wrapper that returns transparency fields alongside
320+
/// the bytes. Routes through the SDK's full Phase 2.x + 3.3 stack:
321+
///
322+
/// | State | Returns |
323+
/// |-----------------------------------|-------------------------------------------|
324+
/// | Master up | source = Master, freshness = Live |
325+
/// | Master down + warm cache hit | source = LocalCache or Gateway(url), |
326+
/// | | freshness = Cached { observed_at } |
327+
/// | Master down + cold-start | source = Gateway(url), |
328+
/// | | freshness = Cached { observed_at } |
329+
/// | Master down + cache miss + no | Err(UsersIndexResolutionFailed) |
330+
/// | resolver configured | |
331+
///
332+
/// Apps that don't care about transparency can read `result.inner.data`.
333+
/// Apps that surface "you're offline" UI inspect `result.source` /
334+
/// `result.freshness`.
335+
///
336+
/// Native-only at runtime: on wasm32 the SDK currently only wraps
337+
/// `get_object_with_metadata` (no offline fallback infrastructure on
338+
/// browsers — block_cache + gateway_fetch are gated out). The wasm
339+
/// path returns `OfflineGetResult` with `source = Master, freshness =
340+
/// Live` so the API shape is identical across platforms.
341+
pub async fn get_object_with_offline_fallback(
342+
client: &FulaClientHandle,
343+
bucket: String,
344+
key: String,
345+
) -> anyhow::Result<OfflineGetResult> {
346+
let result = client
347+
.inner
348+
.get_object_with_offline_fallback(&bucket, &key)
349+
.await?;
350+
Ok(result.into())
351+
}
352+
174353
// ============================================================================
175354
// Bucket Operations
176355
// ============================================================================

0 commit comments

Comments
 (0)