fula_client 0.4.4
fula_client: ^0.4.4 copied to clipboard
Flutter SDK for Fula decentralized storage with client-side encryption, metadata privacy, and secure sharing.
Changelog #
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.4.4 - 2026-05-07 #
Hotfix release. Fixes a fundamental cold-start bug: the publisher was emitting MASTER's bucket Prolly Tree CID (CBOR) as the manifest field, but the SDK's cold-start needs the SDK's encrypted forest manifest CID (JSON envelope). Result: cold-start (offline reads on a fresh device or when master is unreachable) failed for ALL users with serde_json "expected value at line 1 column 1" — CBOR bytes fed into a JSON parser. Cold-start has actually never worked end-to-end against real published data; the bug only manifested once production users tested offline reads.
Fixed #
bucketsIndexCBOR'sBucketEntrynow exposes BOTH master's Prolly Tree CID (legacymanifestfield, kept for forward compatibility) AND the SDK's encrypted forest manifest CID (newforest_manifest_cidfield). Cold-start prefers the new field; falls back to the legacy field when the new field is absent (defensive: also treatsSome("")as absent). Old SDKs reading the new CBOR see an unknown field and ignore it (#[serde(default, skip_serializing_if = "Option::is_none")]); old buckets withoutforest_manifest_cidpopulated continue to fail cold-start (no regression vs. v0.4.3-and-prior). New SDKs reading old CBORs fall back tomanifest(no regression).
Added #
BucketMetadata.forest_manifest_cid: Option<String>— master tracks the latest encrypted forest manifest CID per bucket. Distinct fromroot_cid(master's S3-listing index, used internally by master and pinned for IPFS availability).#[serde(default)]ensures pre-v0.4.4 registry CBORs deserialize without migration. Fully populated and unit-tested viatest_forest_manifest_cid_round_trip_with_lookup_h_setincrates/fula-core/src/metadata.rs.BucketManager::populate_forest_manifest_cid(user_id, bucket_name, cid)atcrates/fula-core/src/bucket.rs— REPLACE-LATEST semantics (NOT set-once likepopulate_lookup_h_if_missing). Idempotent on identical CID input (no extra registry-persist churn). Sets the dirty flag only when the value actually changes.- Master sentinel header
x-amz-meta-fula-forest-manifest: 1on the SDK's Phase 2 manifest root commit (encryption.rs save_sharded_hamt_forest, save_forest, v1→v7 migration). Master sees the sentinel, takes its own server-computed CID for that PUT (the etag), and stores it onBucketMetadata.forest_manifest_cid. SDK does NOT need to recompute and send the CID — server is the source of truth for content-addressing. - Master env flag
FULA_FOREST_MANIFEST_CID_ENABLED=0|1(default 0) gating header consumption — enables independent rollout of master vs SDK. BucketEntry.cold_start_cid()accessor incrates/fula-client/src/registry_resolver.rs— encapsulates the prefer-new-field-fall-back-to-legacy logic in one place. Empty-string defensive parsing.fula-forest-manifestadded toFULA_CONTROL_HEADERS(crates/fula-cli/src/handlers/object.rs) so the sentinel is consumed by handler logic and never persisted as object metadata.
Operational rollout (env-flag-gated, instant rollback) #
| Phase | Action | Observable change |
|---|---|---|
| A | Ship v0.4.4 SDK to apps. SDK starts sending x-amz-meta-fula-forest-manifest: 1 on Phase 2 root PUTs. Pre-v0.4.4 master ignores the header (it's just an x-amz-meta-*). |
None observable. |
| B | Deploy v0.4.4 master with FULA_FOREST_MANIFEST_CID_ENABLED=0 (default). |
None observable. |
| C | Operator flips FULA_FOREST_MANIFEST_CID_ENABLED=1, restarts gateway. |
Master starts populating BucketMetadata.forest_manifest_cid on every Phase 2 commit from a v0.4.4+ SDK. Look for log line "Populated forest_manifest_cid (v0.4.4)". Next publisher tick emits the new CID in the per-user CBOR. Cold-start works. |
| Rollback | Set flag to 0, restart. | Next publisher tick re-emits without forest_manifest_cid. SDK falls back to manifest (broken cold-start, no regression vs. pre-v0.4.4). |
Compatibility matrix #
| SDK | Master | Master flag | Cold-start works? |
|---|---|---|---|
| pre-v0.4.4 | pre-v0.4.4 | n/a | No (the original bug). |
| pre-v0.4.4 | v0.4.4 | OFF | No (no behavior change). |
| pre-v0.4.4 | v0.4.4 | ON | No (pre-v0.4.4 SDK doesn't send sentinel; forest_manifest_cid stays None; SDK falls back to broken manifest). |
| v0.4.4 | pre-v0.4.4 | n/a | No (master ignores sentinel; field never populated). Fall back to broken manifest. |
| v0.4.4 | v0.4.4 | OFF | No (master discards sentinel due to env flag). |
| v0.4.4 | v0.4.4 | ON | Yes ✅ — first regression-free cold-start in fula history. |
Why the legacy manifest field is kept (per operator request) #
Master never reads its own published CBOR back for recovery (verified by audit — master uses on-disk registry_cid_path only). The legacy manifest field thus has exactly one consumer today (the SDK cold-start, which currently fails on it). However, the operator requested keeping it for forward compatibility: if any future tooling wants to walk master's Prolly Tree from the published CBOR (e.g., for disaster-recovery diagnostics, third-party indexing, or audit), the root_cid is still there. The cost is one extra string per bucket per user (~46 bytes); the benefit is zero data corruption risk on rollback.
Limitations #
- Cold-start works only for users who have done at least ONE Phase 2 root commit AFTER master was upgraded to v0.4.4 with the flag on. Users who have only pre-v0.4.4 data (legacy bucket forests with no fresh root commit) need to re-PUT or re-flush to populate
forest_manifest_cid. Same lazy-migration property as Phase 1.2'sbucket_lookup_h. - Empty-bucket users still can't cold-start — without a Phase 2 commit, there's nothing to populate. Cold-start fails cleanly with a
UsersIndexResolutionFailederror referring to the bucket name.
0.4.3 - 2026-05-07 #
Hotfix release. Fixes a silent cold-start failure for pre-migration-011 users (legacy users whose JWT sub claim is plaintext email rather than sha256(email).hex()). Without this fix, those users get a "user has not written yet" error when trying to read their own data offline, even though they have written. Apps should call the new deriveUserKeyFromJwtSub function whenever they have access to the JWT (which they do at sign-in).
Fixed #
- Cold-start userKey derivation now matches master byte-for-byte for ALL users. Master's
crates/fula-cli/src/state.rs::hash_user_iddoesBLAKE3.derive_key("fula:user_id:", claims.sub.as_bytes())[..16]— no transformation of the JWT sub. The previous SDK-sidederive_user_key_from_emailalways pre-hashed withsha256(email)first, which matches master ONLY for post-migration-011 users (whoseclaims.subalready ISsha256(email).hex()). For pre-migration users like the production accountehsan@fx.land(whoseclaims.subis the plaintext email), the SDK'ssha256step diverged from master's. Master stored the user'sbucketsIndexCidunder userKey4da2c0616b1d39660f9f94e145fbce4f(BLAKE3 over plaintext email); the SDK looked upd2df90894e237aa4ef50618e514e0e37(BLAKE3 oversha256(email).hex()). Lookup missed; cold-start failed silently with a misleading "user has not written yet" error. - Fix: new
derive_user_key_from_jwt_sub(jwt_sub)incrates/fula-client/src/user_key.rsmirrors master's algorithm exactly — feeds the JWT sub into BLAKE3 with no transformation. Apps call it at sign-in (the JWT is right there), passing the sub through unchanged. Works for both pre-migration and post-migration users without branching. The legacyderive_user_key_from_emailis kept for source compatibility but is now documented as broken for pre-migration users.
Added #
fula_client::derive_user_key_from_jwt_sub(pure / cross-target) — preferred userKey derivation. 32-hex output, stable per OAuth identity, matches master.- fula-flutter Dart binding
deriveUserKeyFromJwtSubatcrates/fula-flutter/src/api/client.rs— generated automatically by FRB. - fula-js wasm-bindgen binding
deriveUserKeyFromJwtSubatcrates/fula-js/src/lib.rs:1075-1077— exposed asderiveUserKeyFromJwtSubin JS. - Pinned regression tests in
crates/fula-client/src/user_key.rs::tests:derive_user_key_from_jwt_sub_matches_master_for_plaintext_email_sub— assertsderive_user_key_from_jwt_sub("ehsan@fx.land") == "4da2c0616b1d39660f9f94e145fbce4f"(the actual value in master's published CBOR).derive_user_key_from_jwt_sub_matches_legacy_for_sha256_email_sub— asserts the new function on a sha256-hex sub equals the old function on the original email.derive_user_key_from_email_pinned_value— asserts the legacy function's broken-for-legacy-users return value (d2df90...) so future refactors can't silently change the algorithm.derive_user_key_from_jwt_sub_empty_does_not_panic— defense against edge-case input.
Migration guide for apps #
If your app calls deriveUserKeyFromEmail(email) today, switch to deriveUserKeyFromJwtSub(jwt_sub) where jwt_sub is the sub claim from the JWT your auth flow already received. Pre-migration users will start being able to cold-start; post-migration users see no behavior change.
JWT sub extraction is one-line in most languages — for Dart, see the example _extractJwtSub helper in FxFiles' fula_api_service.dart. The fula_client SDK does NOT need the JWT or the email — only the sub string.
If your app cannot get the JWT sub at the call site, keep using deriveUserKeyFromEmail with the caveat that pre-migration users won't be able to cold-start. The legacy function is NOT going to be removed.
Known limitations #
- Already-cached state on device still has the wrong userKey baked in if the app cached the userKey from a previous session. Apps should clear any cached userKey on first run after upgrading to v0.4.3 OR re-derive on every init (cheap operation). FxFiles re-derives on every
FulaApiService.initializecall so no special migration is needed there. - Master-side
state.rs::hash_user_idstill has the underlying inconsistency (task #24 in the master-independent-reads plan). This SDK fix is a workaround that aligns the SDK to master's existing behavior — it does NOT unify the twohash_user_idfunctions inside fula-cli. That cleanup is tracked separately.
Operational #
- No master changes required. Strictly an SDK-side fix.
- No data migration required. Cold-start lookups now hit the right key; existing data is preserved untouched.
- No coordinated rollout required. v0.4.2 master + v0.4.3 SDK works. v0.4.3 master (when bumped to match) + v0.4.2 SDK still works for post-migration users; pre-migration users see the same broken behavior they had before until the SDK is upgraded.
0.4.2 - 2026-05-07 #
Security release. Includes a high-severity PII leak fix and an admin sweep tool to remediate already-leaked data. Operators who deployed v0.4.0 or v0.4.1 with the Phase 3.2 users-index publisher enabled (i.e., FULA_USERS_INDEX_PUBLISHER_ENABLED=1) MUST follow the runbook in crates/fula-cli/src/handlers/admin.rs::pii_sweep. Apps using fula-client 0.4.0/0.4.1 are not affected directly — this is a master-side fix.
Security #
- CRITICAL: Per-object
tags.owner_idleaked the raw JWTsubclaim into bucket Prolly Tree leaves. For pre-migration-011 (legacy) users this is plaintext email; for post-migration users it'ssha256(email)hex (still enumerable). The leaves are content-addressed and pinned to IPFS; the Phase 3.2 publisher then exposed each affected bucket'sroot_cidas themanifestfield of a publicly-fetchable per-userbucketsIndexCBOR. Fixed atcrates/fula-cli/src/handlers/object.rs:127andcrates/fula-cli/src/handlers/multipart.rs:210— both now usesession.hashed_user_id(canonical 16-byte BLAKE3-derived opaque form, matchingBucketMetadata.owner_idand the COPY handler at line 694). Verified safe for download, decryption, share-token issuance, and access control (per-objectowner_idis metadata only; bucket-levelcan_access_bucketalready uses the hashed form). - HIGH: Rate-limit middleware keyed on raw JWT sub.
crates/fula-cli/src/middleware.rs:175now usessession.hashed_user_id. In-memory only, but if metrics are exported (Prometheus, etc.) the raw form would leak. - HIGH: Admin tracing logged raw JWT sub.
crates/fula-cli/src/middleware.rs:144-157(admin-auth log line) andcrates/fula-cli/src/handlers/admin.rs:97-101, 146-150, 176-178(admin handler entry/no-buckets logs) now compute the BLAKE3-hashed form before logging. Note: HTTP response bodies for admin endpoints still echo the raw URL parameteruser_id(admin-supplied; admin auth gates this); tightening the response is a separate API change. - Documented
UserSession.user_idas PII.crates/fula-cli/src/state.rs:170-186doc-comment now explicitly warns: never persist, never log, never return externally; usehashed_user_idinstead. The two legitimate consumers (computinghashed_user_idat session construction, forwarding the raw JWT to the pinning service) are called out. - NEW:
POST /admin/pii-sweependpoint. Admin-authenticated rewrite tool. Walks every bucket's Prolly Tree, identifies objects whoseowner_iddiffers from the canonicalBucketMetadata.owner_id, rewrites them in-memory, and atomically flushes a fresh root_cid per bucket via the existingflush()mechanism. Idempotent, dry-run by default (?dry_run=true), per-bucket detail report. Holds the samebucket_write_lock(hashed_user_id, bucket_name)the regular PUT handler holds, so concurrent uploads serialize naturally without losing user writes. Crash-safe: rewritten buckets are committed atomically; pending buckets stay at pre-sweep state for the next run. Seehandlers::admin::pii_sweepfor full runbook including IMPORTANT note that the sweep does NOT clean up cluster pins of old root_cids (operator must runipfs-cluster-ctl pin rmfor eachdetails[].old_root_cid) and that chain-anchorPublishedevent history is permanent (no mitigation short of contract redeployment, which is out of scope).
0.4.1 - 2026-05-06 #
Follow-up release that closes correctness gaps found while validating v0.4.0's offline-reads end-to-end against a live master. Strictly additive at the API level — no signature changes, no Dart/JS code changes required in apps; just rebuild against the new SDK.
Fixed #
- Offline path no longer masks failures as empty forests.
load_forest_internalpreviously caught every error via a wildcardErr(_)arm and silently created an empty v7 forest, so a master-unreachable read returned 0 files instead of surfacing the outage. Narrowed toErr(e) if e.is_not_found()for the genuine "new bucket" path; every other error now propagates correctly. Apps see real errors during outages instead of empty buckets. (encryption.rs:2569) - Connection-refused / DNS-failure errors now correctly classify as master-unreachable.
is_master_unreachable_erroronly looked atreqwest::Error::is_connect(), which fails to detect connect errors through the reqwest 0.12 + hyper-util wrapper chain. Added a source-chainstd::io::Errorwalker that catchesConnectionRefused / TimedOut / NetworkUnreachable / HostUnreachable / ConnectionReset / ConnectionAborted / NotConnected / AddrNotAvailable / BrokenPipe / NetworkDown. Without this fix, real offline scenarios bypassed the warm-cache fallback entirely. (client.rs::source_chain_has_network_io_error) - v7 sharded-HAMT manifest pages now use the offline-fallback wrapper.
load_manifest_pageswas fetching every page via rawget_object, bypassing the warm cache. Master-down reads of sharded buckets failed even with cache + gateway flags on. Routed throughget_object_with_offline_fallback. Same security model — page bytes are AEAD envelopes decrypted withforest_dekafter fetch; cache stores only ciphertext keyed by content-addressed CID. (encryption.rs:3744-3783) - v7 directory-index also uses the offline-fallback wrapper. Same root cause as manifest pages; same fix.
NotFoundshort-circuits to "rebuild from forest" unchanged. (encryption.rs:3807-3856) - Encrypted offline DOWNLOAD now works for single-object AND chunked files. The encrypted SDK's read path required HTTP
x-fula-encryptionuser-metadata to decrypt — a header that gateways don't preserve and the warm cache didn't capture. The read path (get_object_decrypted_by_storage_key) now falls back to the forest entry'suser_metadatawhen the HTTP header is absent, and the upload path stashes the encryption-metadata JSON ontoforest_entry.user_metadataso future reads are self-describing. The forest blob is AEAD-encrypted withforest_dek(derived from the user's KEK), so the metadata travels privately. AEAD AAD on every chunk binds bytes to theirstorage_key, defeating key-substitution attacks. Forward-only: existing pre-v0.4.1 uploads still need master to be reachable until they're re-uploaded once. (encryption.rs:put_object_flat_deferred,encryption.rs:get_object_decrypted_by_storage_key) - Per-chunk fetches in the chunked-download engine route through
get_object_with_offline_fallback. Chunks themselves carry no per-chunk metadata (DEK from the index, nonce derived from chunk_index), so warm-cache hits are sufficient. Bao streaming verifier still catches truncation/tampering regardless of which channel served the bytes. Files >768 KB (the chunked threshold) now decrypt fully offline. (encryption.rs:download_chunks_windowed_to_writer) FulaUsersIndexAnchorSolidity contract:initializenow also accepts aninitialOperatorargument that's grantedCONTRACT_OPERATOR_ROLEat deploy with the sameROLE_CHANGE_DELAYtimelock as owner/admin. Removes the operational dead-time of the day-one AddRole governance round-trip while preserving the multi-sig discipline for every subsequent operator change. Audit-driven; documented in the deploy script.- Phase 1.2
bucket_lookup_hheader now rides on every Phase 1.5 page PUT and Phase 1.6 dir-index PUT, not only the Phase 2 manifest-root commit. Buckets that flush rarely — or whereflush_forestis deferred — now migrate fromlegacy=truetolegacy=falseon the first chunked upload that dirties any manifest page, instead of waiting for an explicitflushForestcall to fire Phase 2. Master'spopulate_lookup_h_if_missingis idempotent (bucket.rs:1017-1041), so the same hex from multiple PUTs in one flush is a no-op after the first. The lookup-h hex is now hoisted out of the Phase 1.5 dirty-pages loop (computed once per flush, reused across page PUTs, dir-index PUT, and root PUT). No security delta — same per-bucket, per-user blinded value derived from the user's MetadataKey. (encryption.rs:3463-3552, 3586-3587, 3660-3673)
Added #
BlockCacheandBlockCacheErrorre-exported atfula_clientcrate root (pub use block_cache::{BlockCache, BlockCacheError}), gated to native targets. Lets integration tests and operator diagnostic tooling probe cache state without crossing internal-module-path boundaries. The cache itself stores only AEAD-encrypted ciphertext keyed by content-addressed CID — no plaintext, no encryption keys.FileMetadata.userMetadatais now boundary-filtered before returning to apps: keys starting withx-fula-are stripped. Internal SDK plumbing (notablyx-fula-encryptioncarrying the HPKE-wrapped DEK) no longer leaks into UI surfaces like "Properties" dialogs or custom-tag screens. App-set keys are returned unchanged.- End-to-end integration test (
tests/offline_e2e.rs). Three variants — single-object (256 B), chunked (1.5 MB straddling the 768 KB threshold), and a legacy alias. Each phase: upload → fresh-client read against real master (populates warm cache) → bogus-master client (proves cache-served decrypt). Gated#[ignore]; opt in withFULA_JWT+FULA_S3env vars. Validates every fix above against live infrastructure.
Changed #
get_object_decrypted_by_storage_keyroutes throughget_object_with_offline_fallback. Same signature, same master-up behavior; transparently picks up warm-cache offline support. The cache hook on success populatesKEY_TO_CID+BLOCKSfor both index objects and chunks.- Forest entries written by v0.4.1 carry encryption metadata in
user_metadata(x-fula-encrypted,x-fula-encryptionJSON, optionallyx-fula-chunked). Same JSON the master gets in HTTP user-metadata, but stored privately inside the AEAD-encrypted forest blob. Apps that want to read these can grep their own forest entries; the boundary filter (above) hides them from the publicFileMetadata.userMetadatamap. load_forest_internalerrors are no longer self-healing into empty state. Combined with the discriminator fix above, transient outages now propagate to the caller instead of silently caching empty. The next call after master returns re-fetches from scratch (cache stays empty on the failure path).
Bindings #
- No public API changes.
fula-flutterandfula-jscontinue to expose the same Dart / TypeScript surfaces as v0.4.0. Apps just need to bump the dependency version and rebuild. The bug fixes above land automatically. fula-flutter: regeneratedfrb_generated.rsfrom CI on tag push (no manual codegen needed). The Dart bindinggetObjectWithOfflineFallbacknow backs encrypted offline reads via the path throughget_object_decrypted_by_storage_key.fula-js: same — wasm-bindgen surface unchanged; the upstream Rust fixes apply transparently.
Operational #
- Master deploy is unchanged. All v0.4.1 changes are SDK-side. Master operators keep their existing
FULA_BUCKET_LOOKUP_H_ENABLED,FULA_USERS_INDEX_PUBLISHER_ENABLED, etc. settings. - Mixed-version coexistence. A v0.4.0 master + v0.4.1 client works (master ignores client-side improvements). A v0.4.1 master + v0.4.0 client also works (master changes are forward-compatible with old SDKs).
Known Limitations #
- Encrypted offline DOWNLOAD is forward-only. Files uploaded by an SDK older than v0.4.1 don't carry encryption metadata in their forest entries, so reading them while the master is unreachable still fails (clean error: "Missing encryption metadata in headers AND forest entry — re-upload via the new SDK to enable offline reads"). Re-upload migrates lazily; on master-up, every re-upload populates the forest entry. No explicit migration step is required for end users.
- Sibling encrypted-read paths not yet routed through offline-fallback.
get_object_decrypted_to_writer_by_storage_key,get_object_decrypted_buffered_to_writer_by_storage_key, andget_object_with_private_metadatastill use direct master fetch. FxFiles doesn't call these (usesgetFlatonly) but they're tracked for a follow-up release if other apps need the streaming-decrypt offline path.
Migration Guide #
- No code changes. Bump
fula_client(Dart) /fula-js(npm) /fula-client(Rust) to0.4.1, rebuild, redistribute. - No data migration. Existing forests, existing buckets, existing chain entries — all readable as-is.
- Re-upload existing files if you want offline-encrypted reads to cover them too. New uploads are self-describing immediately.
0.4.0 - 2026-05-04 #
Added #
-
Master-independent reads (Phase 2 + 3 + 19). When the master gateway is unreachable, the SDK now transparently falls back to public IPFS gateways AND, on a fresh device install, can cold-start by resolving a globally-published users-index from IPNS or the chain anchor — without a client wallet. End users keep reading their own files even during master outages.
- Phase 2.1 — Master health gate. Lock-free
AtomicU64state machine that observes request outcomes and short-circuits withMasterUnreachableafter two consecutive failures, instead of paying the per-read timeout tax. NewFulaConfigfields:healthGateEnabled,healthGateTtlSeconds. Functional on every target including web. Default OFF for backward-compat. - Phase 2.2 — Persistent block cache. redb-backed LRU cache (default 256 MiB) of fetched encrypted blocks keyed by CID. Populated transparently during master-up reads; serves repeat reads without any network hit during master outages. New fields:
blockCacheEnabled,blockCachePath,blockCacheMaxBytes. Native-only at runtime; the flags are accepted on web for config symmetry but inert. - Phase 2.3 — Multi-gateway race + dynamic priority + CID verification. Six default public IPFS gateways raced K-at-a-time (default K=3) with per-gateway penalty/cooldown state. Every fetched block is re-hashed against the requested CID's multihash (BLAKE3 or SHA2-256) before being trusted. New fields:
gatewayFallbackEnabled,gatewayFallbackUrls,gatewayRaceConcurrency. Native-only. - Phase 2.4 — Wired warm-device offline GET. New
getObjectWithOfflineFallbackreturnsOfflineGetResultwith bytes + transparency. Master-up reads serve normally; master-down reads fall through to the gateway race using the cached(bucket, key) → cidmapping. Cold-start (cache miss) propagatesMasterUnreachablefor the resolver to handle. - Phase 3.2 — Master-side users-index publisher. Master gateway now periodically (every 5 min by default) builds per-user
bucketsIndexCBORs + a global users-index CBOR, pins them via cluster, publishes to IPNS, and a 12h cron inmainnet-rewards-serversubmits the same CID to aFulaUsersIndexAnchorcontract on Base/SKALE. Two chain writes per day, fixed forever, gas-defensive against future Base pricing. Server-side change; SDK consumes via Phase 3.3. - Phase 3.3 — Cold-start hybrid resolver (IPNS-first → chain-fallback). New
FulaConfigfields:usersIndexChainRpcUrl,usersIndexAnchorAddress,usersIndexIpnsName,usersIndexUserKey,usersIndexIpnsGatewayUrls,usersIndexIpfsGatewayUrls. New free functionderiveUserKeyFromEmail(email)— apps call once at sign-in to derive the userKey; SDK never sees the raw email. Resolver activates iff all four required fields are populated; fresh-install fresh-master-down reads now succeed. Native-only at runtime; web surfaces typedUsersIndexResolutionFailederrors. - Phase 19 — Transparency surfaces.
OfflineGetResult { inner, source: ReadSource, freshness: ReadFreshness },MasterHealthEventenum (Online | OfflineFallbackActive | SeverelyDegraded). New polling APIspollMasterHealthEvents(client)andgetLastMasterHealthEvent(client)so apps can drive online/offline UI affordances. The Rust core also exposes a closure-basedHealthCallback; the FRB and wasm-bindgen bindings expose the polling form for cross-target ergonomics.
- Phase 2.1 — Master health gate. Lock-free
Changed #
getObjectWithOfflineFallbackreturn type is nowOfflineGetResult(wasGetObjectResult). Master-up reads returnsource: Master, freshness: Liveso existing callers that only read.inner.dataneed a one-character change. The pre-existinggetObjectWithMetadatais unchanged.PublishNowResponse(master-side admin endpoint) gains afailed_usersfield exposing the per-user-error-tolerance count fromTickOutcome.- Per-user error tolerance in master publisher. A single user's CBOR pin failure no longer aborts the whole tick; succeeded users still get published, failed users keep their prior CID, and they retry on the next tick.
TickOutcomegainsfailed_users: usize.
Bindings #
- fula-flutter (Dart) — every Phase 2.x / 3.3 / 19 surface plumbed: 6 new Phase 3.3 config fields + 2 new types (
OfflineGetResult,MasterHealthEvent) + 2 new enum types (FulaReadSource,FulaReadFreshness) + free functionderiveUserKeyFromEmail+ methodgetObjectWithOfflineFallback+ pollingpollMasterHealthEvents/getLastMasterHealthEvent(+ encrypted-handle variants). Generated Dart bindings +frb_generated.rsare regenerated by CI on tag push. - fula-js (wasm-bindgen / TypeScript) — same surfaces exposed via serde-tagged JS objects. Cross-target
deriveUserKeyFromEmailextracted fromregistry_resolverto a wasm-friendlyuser_keymodule so JS apps can compute the userKey on web. - Error mapping — both bindings cover the new
UsersIndexResolutionFailed,SequenceRegression,BlockTooLarge, andBlockCacheErrorvariants.
Operational #
- New admin endpoints for triggering an immediate publisher tick / chain anchor submit without waiting up to 12h:
fula-cli:POST /_internal/publish-now(already existed; response now includesfailed_users)mainnet-rewards-server:POST /admin/users-index-anchor/trigger(new) — bearer-protected, fail-closed 503, 409 on contentionpinning-webui: new admin tab "Fula Publisher" with two buttons proxying through/api/admin/fula/publish-nowand/api/admin/fula/anchor-now
- Master deploy is backward-compat: every new server-side path is gated by an env flag default-OFF. Old fula-clients (running pre-0.4.0 SDK) continue to work byte-identically against an 0.4.0 master.
Migration Guide #
- Existing apps reading bytes: change
result.data→result.inner.dataif you're usinggetObjectWithOfflineFallback.getObjectWithMetadatacallers unchanged. - To enable warm-device offline reads: set
healthGateEnabled = true,blockCacheEnabled = true,gatewayFallbackEnabled = trueonFulaConfig. Native-only; safe to set on web (silently inert). - To enable cold-start (fresh device install while master is down): in addition to the warm-device flags, populate
usersIndexChainRpcUrl,usersIndexAnchorAddress,usersIndexIpnsName(operator-supplied at deploy), andusersIndexUserKey(computed viaderiveUserKeyFromEmail(email)at sign-in). Native-only at runtime. - No data migration required. Existing on-chain / IPFS / S3 data remains readable through every new code path AND through the existing master path.
0.3.0 - 2026-04-01 #
Internal SDK refactors and incremental fixes between v0.2.18 and v0.4.0; see git history for the full set. The user-facing API additions are consolidated under v0.4.0 above.
0.2.18 - 2026-01-13 #
Fixed #
- Android 16KB page size support for Android 15+ (API 35)
- Native libraries (.so files) now compiled with 16KB page alignment
- Added
-Wl,-z,max-page-size=16384linker flag to all Android targets in CI - Apps can now target Android 15 without Google Play warnings
0.2.17 - 2026-01-13 #
Fixed #
- CRITICAL: Share tokens missing encryption nonce - decryption produces garbage
- Share tokens only contained wrapped DEK but not the nonce needed for decryption
- Web UI proxy doesn't forward S3 metadata headers (
x-fula-encryption) - Without the nonce, decryption "succeeds" but produces garbage data
- Fix: Share tokens now include
nonce(for single-block files) andchunked_metadata(for chunked files) - Recipients can now decrypt using just the share token without needing S3 metadata headers
Changed #
ShareTokenstruct now includes optionalnonceandchunked_metadatafieldsShareBuilderhas new.nonce()and.chunked_metadata()builder methodsAcceptedSharenow carries nonce and chunked metadata through to decryptionget_object_with_shareuses nonce from share token if available, falls back to S3 headers for backwards compatibility- Share token version bumped to 3
Migration Guide for FxFiles #
Share tokens created with v0.2.17+ will automatically include the nonce. No code changes needed - just rebuild FxFiles with the new fula_client SDK.
Old share tokens (without nonce) will continue to work if the proxy forwards S3 headers correctly.
0.2.16 - 2026-01-13 #
Fixed #
- CRITICAL: Share decryption fails for chunked files (files > 768KB)
get_object_with_sharewas using single-block decryption for all files- Chunked files store each chunk with its own nonce in
{storage_key}.chunks/{index} - Share flow was ignoring chunked file metadata and trying to decrypt assembled bytes as single block
- Result: Large shared files (images, videos) returned garbage data instead of correct content
- Fix:
get_object_with_sharenow checksx-fula-chunkedmetadata and usesChunkedDecoderwith per-chunk nonces when needed
Technical Details #
- Added
get_object_chunked_with_share()internal method for chunked file handling in share flow - Downloads each chunk from
{storage_key}.chunks/{index}, decrypts with chunk-specific nonce - Concatenates decrypted chunks and returns complete plaintext
- Works identically to normal
get_object_decrypted_by_storage_key()but uses share's DEK
0.2.15 - 2026-01-13 #
Fixed #
-
CRITICAL: flutter_rust_bridge content hash mismatch in CI
build-androidandbuild-iosjobs were building native libraries from committedfrb_generated.rsgenerate-bindingsjob was creating freshfrb_generated.dartwith different content hash- This caused "Content hash on Dart side is different from Rust side" error
- Fix: Both Android and iOS build jobs now run
flutter_rust_bridge_codegen generatebefore building
-
CRITICAL: X25519 public key derivation mismatch between Dart and Rust
- When sharing files via public links, FxFiles was using Dart's
cryptographypackage to derive X25519 public keys - The Web UI uses Rust (via WASM) to derive public keys from the same private key bytes
- Different implementations may produce different public keys from the same private key seed
- This caused HPKE key wrapping to fail: the share token encrypted DEK for Dart's public key, but the web UI derived a different public key from the private key in the URL
- Fix: Added
derivePublicKeyFromSecret()function to both Flutter and JS/WASM bindings - Required FxFiles change: Use
derivePublicKeyFromSecret(secretKeyBytes)instead of Dart's native X25519 derivation
- When sharing files via public links, FxFiles was using Dart's
Added #
derivePublicKeyFromSecret(Vec<u8>)- Flutter API function to derive X25519 public key from private key bytes using Rust's x25519_dalekderivePublicKeyFromSecret(Uint8Array)- JS/WASM function for the same purpose- Comprehensive tests verifying end-to-end share flow compatibility
Migration Guide for FxFiles #
Replace this Dart code:
final x25519 = X25519();
final keyPair = await x25519.newKeyPair();
final publicKeyBytes = Uint8List.fromList((await keyPair.extractPublicKey()).bytes);
final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
With this:
import 'dart:math';
// Generate random 32 bytes
final privateKeyBytes = Uint8List(32);
Random.secure().nextBytes(privateKeyBytes);
// Derive public key using Rust (ensures cross-platform compatibility)
final publicKeyBytes = await derivePublicKeyFromSecret(privateKeyBytes);
0.2.12 - 2026-01-13 #
Fixed #
- CRITICAL: Share token DEK mismatch bug: Fixed share tokens using derived DEK instead of actual uploaded DEK
- In FlatNamespace mode, files are encrypted with random DEKs stored in metadata
- Share token creation was incorrectly deriving DEK from path instead of fetching actual DEK from metadata
- This caused all shared files to fail decryption on recipient side (garbage output)
- Fix:
create_share_tokenandcreate_share_token_with_modenow fetch wrapped DEK from object metadata
Changed #
- API Breaking Change:
createShareTokenandcreateShareTokenWithModenow requirebucketparameter- Flutter:
createShareToken(bucket: 'mybucket', storageKey: '...', ...) - This is needed to fetch object metadata containing the actual DEK
- Flutter:
Added #
- Comprehensive sharing tests verifying:
- Share token uses correct (uploaded) DEK
- Different files have different random DEKs (isolation)
- Sharing one file does not expose other files
- Wrong recipient cannot decrypt share tokens
- Path scope enforcement
- Expiration handling
0.2.11 - 2026-01-13 #
Fixed #
- WASM time compatibility bug: Fixed
time not implemented on this platformpanic when validating share tokens in browser - Added centralized
time::now_timestamp()function usingjs_sys::Date::now()for WASM andstd::time::SystemTimefor native
Changed #
- Updated
fula-cryptoto use WASM-compatible time functions in sharing, inbox, private_metadata, and subtree_keys modules
0.2.10 - 2026-01-12 #
Added #
- New
@functionland/fula-clientnpm package with high-level JavaScript APIs - WASM bindings using wasm-bindgen (replaces low-level flutter_rust_bridge exports)
- Cross-platform key derivation compatibility between Flutter and JavaScript
- Functions:
createEncryptedClient,getDecrypted,putEncrypted,deriveKey,acceptShare,getWithShare
Changed #
- GitHub Actions workflows updated to build and publish fula-js npm package
0.2.8 - 2026-01-11 #
Changed #
- Minimum Flutter version raised to 3.38.0 (Dart 3.10.x)
- Minimum Dart SDK raised to 3.8.0 (required for freezed ^3.2.0)
- CI/CD workflows updated to use Flutter 3.38.0 stable
- Web plugin updated to use
package:webanddart:js_interop(replacing deprecateddart:htmlanddart:js)
0.2.7 - 2026-01-11 #
Changed #
- Minimum Flutter version raised to 3.27.0 (Dart 3.6.0 required for freezed 3.x)
- Minimum Dart SDK raised to 3.6.0
- CI/CD workflows updated to use Flutter 3.27.0
0.2.6 - 2026-01-11 #
Changed #
- Updated
freezed_annotationto ^3.1.0 for compatibility with other packages - Updated
freezedto ^3.2.0 - Updated
flutter_lintsto ^5.0.0 - Updated
ffigento ^14.0.0
0.2.5 - 2026-01-11 #
0.2.3 - 2026-01-11 #
Changed #
- iOS binaries now downloaded from GitHub Releases during pod install
- This reduces pub.dev package size from 160MB to ~12MB
Fixed #
- Strip debug symbols from native libraries to reduce package size
- Fixed Android NDK compiler configuration (CC/AR environment variables)
- Disabled wasm-opt to fix bulk memory operations error
0.2.1 - 2026-01-11 #
Added #
- GitHub Actions CI workflow for automated testing
- GitHub Actions release workflow for publishing to pub.dev and npm
- iOS XCFramework support for device and simulator builds
Changed #
- Switched from parking_lot to tokio::sync for async-safe locks
- Made async runtime conditional: tokio on native, async-lock on WASM
- Updated iOS podspec to use XCFramework instead of static library
- Improved flutter_rust_bridge compatibility with anyhow::Result
Fixed #
- WASM build now compiles correctly without tokio OS-specific dependencies
- Android namespace updated from fula_flutter to fula_client
- Fixed flutter_rust_bridge codegen configuration
0.2.0 - 2026-01-10 #
Added #
- FlatNamespace obfuscation mode - Complete structure hiding for maximum privacy
- PreserveStructure obfuscation mode - Keep folder paths, hash filenames only
- All 4 obfuscation modes now available:
flatNamespace,deterministic,random,preserveStructure
Changed #
- Minimum SDK version raised to 3.3.0 (required for inline-class feature)
- Minimum Flutter version raised to 3.19.0
- FlatNamespace is now the recommended default for new projects
Fixed #
- Documentation updated to match actual API signatures
0.1.0 - 2024-01-09 #
Added #
- Initial release of fula_client Flutter SDK
- Client-side encryption with AES-256-GCM
- Metadata privacy with configurable obfuscation modes
- Secure file sharing with capability-based tokens
- Key rotation support
- Flat namespace API for file system-like access
- Android support via FFI
- Web support via WASM
- Multipart upload support for large files
Security #
- HPKE (Hybrid Public Key Encryption) for key exchange
- BLAKE3 for fast, secure hashing
- X25519 for elliptic curve Diffie-Hellman
[Unreleased] #
Planned #
- iOS support
- Desktop support (Windows, macOS, Linux)
- Offline-first sync capabilities
- Background upload/download