diff --git a/crates/engine/src/content/provider.rs b/crates/engine/src/content/provider.rs index 5466b793d..07e7e08b0 100644 --- a/crates/engine/src/content/provider.rs +++ b/crates/engine/src/content/provider.rs @@ -314,6 +314,24 @@ pub enum ProviderError { AddressMismatch, } +impl ProviderError { + /// The stable check name a host branches on. Never carries the endpoint or + /// the credential. + pub fn check(&self) -> &'static str { + match self { + ProviderError::InvalidEndpoint => "byo-endpoint-invalid", + ProviderError::InsecureTransport => "byo-endpoint-insecure", + ProviderError::BlockedAddress => "byo-endpoint-blocked", + ProviderError::InvalidCredential => "byo-credential-invalid", + ProviderError::Unreachable => "byo-unreachable", + ProviderError::NoVerdict => "byo-no-verdict", + ProviderError::Rejected { .. } => "byo-rejected", + ProviderError::MalformedBlockAddress => "byo-block-address-malformed", + ProviderError::AddressMismatch => "byo-address-mismatch", + } + } +} + /// Test that a member's BYO provider is reachable and authenticated, engine-side /// over the Http seam. Issues the provider's standard health/auth probe and /// treats any 2xx as success. diff --git a/crates/engine/src/entropy.rs b/crates/engine/src/entropy.rs index 4ce135b95..5addbab78 100644 --- a/crates/engine/src/entropy.rs +++ b/crates/engine/src/entropy.rs @@ -7,6 +7,7 @@ //! injected — engine logic never calls an RNG directly — so tests substitute //! the test kit's seeded source and every seed and nonce becomes reproducible. +use core::cell::RefCell; use core::fmt; use cipherbox_core::suite::aead::NONCE_LEN; @@ -66,6 +67,20 @@ impl Entropy for Box { } } +/// A shared [`Entropy`] cell as an [`Entropy`] source that re-borrows per draw. +/// +/// The engine holds one boxed source behind a [`RefCell`] shared with every +/// spawned loop, and an async port that takes `&mut dyn Entropy` would +/// otherwise hold the `RefMut` across each `.await` — a panic the moment a +/// loop drew from the same cell. +pub(crate) struct SharedEntropy<'a>(pub &'a RefCell>); + +impl Entropy for SharedEntropy<'_> { + fn fill(&mut self, dest: &mut [u8]) -> Result<(), EntropyError> { + self.0.borrow_mut().fill(dest) + } +} + /// A fresh 32-byte HPKE ephemeral scalar, or a closed failure. /// /// Reuse across two seals under one recipient key is a confidentiality break, so diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index 3e25e1317..9a02e6d8b 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -38,25 +38,30 @@ use crate::content::{ RootManifest, SealError, SessionBearer, StagingLedger, open_content_range, open_content_root, pre_flight_quota_check, read_pinned_range, sealed_total_bytes, }; -use crate::entropy::Entropy; +use crate::entropy::{Entropy, SharedEntropy}; use crate::gate::{GateError, floor}; use crate::grants::{Contact, ContactStore, ContactStoreError, StagingContactStore}; +use crate::net::record_publish::RecordPublishError; use crate::net::retire::{OrphanHeads, retire}; use crate::net::{ Adopter, ChildAdopter, ChildResolveError, EolRenewResult, FolderRefresh, HeldMaterial, HeldRecord, HeldRecords, LivenessControl, PublishError, PublishOutcome, RE_PUT_INTERVAL, RecordPointerFetch, ResolveOutcome, RootAdopter, VaultProvisionNet, eol_renew_pass, - keyless_re_put, refresh_base_from_outcome, resolve_and_hold, resolve_child, run_liveness_loop, + fanout_get_verify, keyless_re_put, refresh_base_from_outcome, resolve_and_hold, resolve_child, + run_liveness_loop, }; use crate::owner_keys::OwnerSessionKeys; use crate::profile::SyncTimingProfile; use crate::rotation::derive_write_name; use crate::seams::{ - FloorStore, OpId, Scheduler, SeamError, SeamResult, SeamSet, SeamTypes, StagingStore, - UnixMillis, + FloorStore, OpId, RecordTransport, Scheduler, SeamError, SeamResult, SeamSet, SeamTypes, + StagingStore, UnixMillis, }; use crate::session::SessionIdentity; -use crate::settings::{PlacementDecision, PlacementRefusal, decide_placement, load_settings}; +use crate::settings::{ + PlacementDecision, PlacementRefusal, SettingsPublishError, VaultSettings, decide_placement, + load_settings, placement_of, publish_settings, +}; use crate::storage_policy::StoragePolicy; use crate::sync::boot::{ColdStartError, ColdStartOutcome, ColdStartParams, cold_start}; use crate::sync::cancel::UploadCancels; @@ -558,6 +563,15 @@ pub enum Command { node: NodeId, }, + // --- vault settings --- + /// Publish the account's vault settings record — the member's placement, + /// provider and retention choice. A confirmed publish binds this session + /// and enrols the name for renewal. + SaveVaultSettings { + /// The settings to seal and publish. + settings: VaultSettings, + }, + // --- auth --- /// Exchange a host-collected SIWE wallet signature (secondary method; /// the engine performs the exchange through its API client). @@ -590,6 +604,7 @@ impl Command { Command::CreateInviteLink { .. } => "createInviteLink", Command::AcceptShare { .. } => "acceptShare", Command::RotateNow { .. } => "rotateNow", + Command::SaveVaultSettings { .. } => "saveVaultSettings", Command::SiweLogin { .. } => "siweLogin", Command::Logout => "logout", } @@ -956,6 +971,44 @@ impl EngineError { } } + /// Map a settings-publish failure. The split is retryability: a refusal + /// that is deterministic in the settings offered is an input the host must + /// change, and reporting it as availability would leave a host retrying a + /// save that can never land. + fn from_settings_publish(err: SettingsPublishError) -> Self { + match err { + SettingsPublishError::Placement(refusal) => EngineError::NoPlacement { refusal }, + SettingsPublishError::Byo(e) => EngineError::MalformedInput { check: e.check() }, + SettingsPublishError::Codec(e) => EngineError::MalformedInput { check: e.check() }, + // The sealed record does not reopen under the key its own reader + // re-derives, or is past the block ceiling: an encoder verdict on + // these bytes, not an outage (security rule 8). + SettingsPublishError::Preflight(_) => EngineError::MalformedInput { + check: "settings-record-preflight", + }, + SettingsPublishError::Entropy(e) => EngineError::from_entropy(e), + // The API answered about a block other than the one uploaded, so + // publishing on its answer would sign a pointer to bytes nothing + // confirmed — a fail-closed verdict, never an outage to retry. + SettingsPublishError::Publish(RecordPublishError::HeadCidMismatch { .. }) => { + EngineError::TrustViolation { + message: "the API echoed a different address for the settings head block" + .to_owned(), + } + } + SettingsPublishError::Publish(_) => EngineError::Seam { + message: "the settings record did not reach the record plane".to_owned(), + }, + SettingsPublishError::Unconfirmed => EngineError::Seam { + message: "the settings publish was not confirmed on re-resolve".to_owned(), + }, + SettingsPublishError::Floor(e) => EngineError::from_seam(e), + SettingsPublishError::Revision => EngineError::Seam { + message: "the durable settings revision counter did not advance".to_owned(), + }, + } + } + /// Map a cold-start failure onto the facade error: every trust arm (forged /// pointer, regressed floor, rejected root) collapses to the single /// fail-closed [`ColdStart`](EngineError::ColdStart) — never retryable @@ -1480,6 +1533,45 @@ impl From for EngineError { /// Map a content-sealing failure onto the facade error: entropy is fail-closed /// availability, and an assembly refusal is a version this build's own reader /// would reject. +/// The settings record this session published, unless the record plane now +/// serves a different one. +/// +/// The resolve tick replaces each held record in place, so nothing in that map +/// can go stale under the renewal; the settings slot has no such refresher. A +/// second device that saved after this session did leaves this record +/// superseded, and a sub-EOL renewal would re-sign it at `floor + 1` with a +/// fresh validity — which wins record selection and rolls the account back to +/// the body this session published, credentials and placement included. +/// +/// Only a positively observed *different* record supersedes: a plane this pass +/// cannot read is availability, and the renewal itself refuses to renew what it +/// cannot resolve. +async fn live_settings_record( + transport: &R, + slot: &RefCell>, +) -> Option { + let held = slot.borrow().clone()?; + let Ok(name) = IpnsName::parse(&held.routing_key) else { + return None; + }; + match fanout_get_verify(transport, &name).await { + Some((live, _)) if live.value != format!("/ipfs/{}", held.head_cid).into_bytes() => { + // The verdict names the record this pass read, not whatever the slot + // holds now: a save that landed across the resolve installed its own + // confirmed record, and clearing that one drops it from the renewal. + let mut slot = slot.borrow_mut(); + if slot + .as_ref() + .is_some_and(|current| current.record_bytes == held.record_bytes) + { + *slot = None; + } + None + } + _ => Some(held), + } +} + fn seal_error(error: SealError) -> EngineError { match error { SealError::Entropy(error) => EngineError::from_entropy(error), @@ -1640,6 +1732,11 @@ pub struct Engine { /// gate-passing record here, and the cold-start liveness loop keyless /// re-PUTs the map's values on the hourly cadence. held_records: Rc>, + /// The vault settings record this session published, in its own slot rather + /// than in [`held_records`](Self::held_records): that map is keyed by node + /// id and the settings record has none, so a synthetic id would put it in a + /// slot a resolved record could claim and evict its renewal. + settings_record: Rc>>, /// Staleness bookkeeping shared with the resolve-tick loop: it stamps /// successes and reports rung changes; [`snapshot`](Self::snapshot) /// classifies at read time off the same cell. @@ -1705,9 +1802,9 @@ pub struct Engine { /// — the config it holds carries the member's provider bearer. placement: Rc>>, /// Whether this session has already held the account's `byo` flag to the - /// vaulted mode. Once a session: the flag is account-wide and the mode is - /// fixed at [`start`](Self::start), so re-deriving it per write would only - /// let two devices flap it. + /// vaulted mode. Latched per placement decision, not per write: the flag is + /// account-wide, so re-deriving it on every write would let two devices flap + /// it — a saved settings change is the one event that re-arms it. byo_reconciled: Cell, /// The one shared API client, built and logged in at [`start`](Self::start) /// and handed to the liveness loop so the access JWT is shared across @@ -1749,6 +1846,7 @@ impl Engine { // the base snapshot; children come from the pending-op overlay. snapshot: Rc::new(RefCell::new(Snapshot::new(NodeId([0u8; 16])))), held_records: Rc::new(RefCell::new(HeldRecords::new())), + settings_record: Rc::new(RefCell::new(None)), sync_status: Rc::new(RefCell::new(SyncStatus::default())), scope_read_seeds: Rc::new(RefCell::new(BTreeMap::new())), scope_write_seeds: Rc::new(RefCell::new(BTreeMap::new())), @@ -1786,8 +1884,6 @@ impl Engine { /// the identity is built. pub async fn start(&mut self, secret: LoginSecret) -> Result<(), EngineError> where - T::Scheduler: Clone + 'static, - T::RecordTransport: Clone + 'static, T::Http: Clone + 'static, T::CredentialStore: Clone + 'static, T::FloorStore: Clone + 'static, @@ -1991,6 +2087,9 @@ impl Engine { if let Ok(mut held) = self.held_records.try_borrow_mut() { held.clear(); } + if let Ok(mut settings) = self.settings_record.try_borrow_mut() { + *settings = None; + } // Each open stream pins a version's content key; releasing the table's // `Rc`s here is what makes this the terminal owner (security rule 7). if let Ok(mut streams) = self.streams.try_borrow_mut() { @@ -2171,10 +2270,7 @@ impl Engine { api: &Rc>, root_scope_id: [u8; 16], ) -> Result - where - T::RecordTransport: Clone + 'static, - T::Scheduler: Clone + 'static, - { +where { let session = self.session.as_ref().expect("session set by start"); let owner_identity = session.owner_identity(); let publisher = VaultProvisionNet { @@ -2214,8 +2310,6 @@ impl Engine { /// parked; the alive latch then stops it. fn spawn_liveness_loop(&self, api: Rc>) where - T::Scheduler: Clone + 'static, - T::RecordTransport: Clone + 'static, T::Http: Clone + 'static, T::CredentialStore: Clone + 'static, T::FloorStore: Clone + 'static, @@ -2225,6 +2319,7 @@ impl Engine { let floors = self.seams.floor_store.clone(); let profile = self.profile; let held = self.held_records.clone(); + let settings_record = self.settings_record.clone(); let alive = self.alive.clone(); let events = self.events.clone(); self.seams.scheduler.spawn(Box::pin(async move { @@ -2232,7 +2327,9 @@ impl Engine { if !alive.get() { return LivenessControl::Stop; } - let records: Vec = held.borrow().values().cloned().collect(); + let settings = live_settings_record(&transport, &settings_record).await; + let records: Vec = + held.borrow().values().cloned().chain(settings).collect(); keyless_re_put(&transport, &records).await; // Surface every renewal that did not land (LostRace/PublishError) // as an Event — never a silent failure (blueprint/engine.md). @@ -2262,8 +2359,6 @@ impl Engine { root_name: Option, api: Rc>, ) where - T::Scheduler: Clone + 'static, - T::RecordTransport: Clone + 'static, T::Http: Clone + 'static, T::CredentialStore: Clone + 'static, T::FloorStore: Clone + 'static, @@ -2665,6 +2760,10 @@ impl Engine { }) } Command::ManualRefresh => self.manual_refresh().await.map(|()| CommandOutcome::Done), + Command::SaveVaultSettings { settings } => { + self.save_vault_settings(&settings).await?; + Ok(CommandOutcome::Done) + } Command::SiweLogin { message, signature } => { let api = self.api.as_ref().ok_or(EngineError::NotStarted)?; api.siwe_login(&message, &hex_lower(&signature)) @@ -2678,6 +2777,35 @@ impl Engine { } } + /// Seal and publish the vault settings record, then adopt what it + /// published: the renewal enrolment [`publish_settings`] states the need + /// for, and the placement this session writes under. + async fn save_vault_settings(&self, settings: &VaultSettings) -> Result<(), EngineError> { + let session = self.session.as_ref().ok_or(EngineError::NotStarted)?; + let api = self.api.as_ref().ok_or(EngineError::NotStarted)?; + let held = publish_settings( + &self.seams.record_transport, + api, + &self.seams.floor_store, + &self.seams.snapshot_cache, + &self.seams.scheduler, + &self.profile, + &mut SharedEntropy(&self.entropy), + &self.orphan_heads, + session.login_secret(), + settings, + ) + .await + .map_err(EngineError::from_settings_publish)?; + *self.settings_record.borrow_mut() = Some(held); + // The confirm re-resolve read back our own bytes, so this device has + // adopted what it published: the session's byte destinations follow, or + // an `External` save keeps feeding the hosted leg until the next start. + *self.placement.borrow_mut() = Some(placement_of(settings)); + self.byo_reconciled.set(false); + Ok(()) + } + /// The scope's cached read seed, evicted first if the durable read-epoch /// floor has risen past the one it was recovered under. Every on-demand /// read goes through here; the resolve tick evicts once per pass. @@ -3797,7 +3925,12 @@ mod tests { use serde_json::{Value, json}; - use crate::seams::{CredentialStore, HttpResponse, UnixMillis}; + use cipherbox_core::ipns::IpnsRecord; + use cipherbox_core::kdf; + + use crate::seams::{CredentialStore, EndpointId, HttpResponse, UnixMillis}; + use crate::settings::settings_name; + use crate::testkit::fakes::InMemoryRecordStore; use crate::testkit::{FakeDevice, FakeSeamTypes, FakeWorld, SeededEntropy, block_on}; /// A destination the render cannot walk to the root is refused, and one it @@ -3826,6 +3959,118 @@ mod tests { )); } + /// A transport that lands a settings save into `slot` before the resolve it + /// wraps can answer — the interleaving a single-threaded executor allows at + /// any `.await`. + struct SavesAcrossTheResolve { + inner: InMemoryRecordStore, + slot: Rc>>, + saved: HeldRecord, + } + + impl RecordTransport for SavesAcrossTheResolve { + fn endpoints(&self) -> Vec { + self.inner.endpoints() + } + + async fn get_record( + &self, + endpoint: &EndpointId, + routing_key: &str, + max_bytes: usize, + ) -> SeamResult>> { + *self.slot.borrow_mut() = Some(self.saved.clone()); + self.inner + .get_record(endpoint, routing_key, max_bytes) + .await + } + + async fn put_record( + &self, + endpoint: &EndpointId, + routing_key: &str, + record: &[u8], + ) -> SeamResult<()> { + self.inner.put_record(endpoint, routing_key, record).await + } + } + + const SETTINGS_SECRET: [u8; 32] = [7u8; 32]; + + /// A held settings record at `head` and `sequence`, signed by the name's + /// own keypair so the resolve verifies it. + fn settings_held(head: &str, sequence: u64) -> HeldRecord { + const TTL_NANOS: u64 = 2_000_000_000; + const EOL: &str = "2099-01-01T00:00:00Z"; + HeldRecord { + routing_key: settings_name(&SETTINGS_SECRET).as_str().to_owned(), + record_bytes: IpnsRecord::create_v2( + &kdf::settings_ipns_keypair(&SETTINGS_SECRET), + format!("/ipfs/{head}").as_bytes(), + sequence, + TTL_NANOS, + EOL, + ) + .marshal(), + signer: kdf::settings_ipns_keypair(&SETTINGS_SECRET), + head_cid: head.to_owned(), + content_cids: Vec::new(), + } + } + + /// Resolve `superseded` against a plane a second device published over, + /// with `saved` landing in the slot across the resolve. Answers what the + /// slot holds afterwards. + fn resolve_with_a_save_across_it( + superseded: HeldRecord, + saved: HeldRecord, + ) -> Option { + let name = settings_name(&SETTINGS_SECRET); + let inner = InMemoryRecordStore::new(vec![EndpointId::new("fake:someguy")]); + let live = settings_held("bafyseconddevicehead", 2).record_bytes; + for endpoint in inner.endpoints() { + inner.seed_record(&endpoint, name.as_str(), live.clone()); + } + + let slot = Rc::new(RefCell::new(Some(superseded))); + let transport = SavesAcrossTheResolve { + inner, + slot: Rc::clone(&slot), + saved, + }; + assert!(block_on(live_settings_record(&transport, &slot)).is_none()); + slot.borrow().clone() + } + + /// The superseded verdict names the record that pass read. A save that + /// landed across the resolve installed its own confirmed record, and + /// clearing that one would drop the live settings from the keyless re-PUT + /// and the EOL renewal for the rest of the session. + #[test] + fn a_save_that_lands_across_the_resolve_keeps_its_record_in_the_renewal() { + let saved = settings_held("bafysavedhead", 3); + assert_eq!( + resolve_with_a_save_across_it(settings_held("bafysupersededhead", 1), saved.clone()) + .map(|held| held.record_bytes), + Some(saved.record_bytes), + "the superseded record was cleared, not the save that replaced it" + ); + } + + /// A head CID does not name a record: the same head re-signed at a higher + /// sequence is a different record, and the renewal has to keep it. + #[test] + fn a_save_across_the_resolve_survives_even_at_the_head_it_replaces() { + const HEAD: &str = "bafysupersededhead"; + let saved = settings_held(HEAD, 3); + assert_eq!( + resolve_with_a_save_across_it(settings_held(HEAD, 1), saved.clone()) + .map(|held| held.record_bytes), + Some(saved.record_bytes), + "a save sharing the inspected head CID is still a different record" + ); + } + /// Shaped as the API issues one; the engine signs nothing else. const LOGIN_CHALLENGE_FIXTURE: &str = "cipherbox-login:v2:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; diff --git a/crates/engine/src/seams/mod.rs b/crates/engine/src/seams/mod.rs index 2afb49b78..0d790ca54 100644 --- a/crates/engine/src/seams/mod.rs +++ b/crates/engine/src/seams/mod.rs @@ -91,14 +91,18 @@ pub type SeamResult = Result; pub trait SeamTypes { /// Durable floor storage ([`FloorStore`]). type FloorStore: FloorStore; - /// Dumb `/routing/v1` byte mover ([`RecordTransport`]). - type RecordTransport: RecordTransport; + /// Dumb `/routing/v1` byte mover ([`RecordTransport`]). `Clone + 'static` + /// because the publish port hands a handle to the background re-PUT it + /// spawns on the scheduler. + type RecordTransport: RecordTransport + Clone + 'static; /// Plain HTTP ([`Http`]). type Http: Http; /// Sealed-blob mailbox transport ([`Mailbox`]). type Mailbox: Mailbox; - /// Timers, background tasks, wall clock ([`Scheduler`]). - type Scheduler: Scheduler; + /// Timers, background tasks, wall clock ([`Scheduler`]). `Clone + 'static` + /// for the same reason as [`RecordTransport`](Self::RecordTransport): the + /// spawned task owns a handle of its own. + type Scheduler: Scheduler + Clone + 'static; /// Durable op queue and staged bytes ([`StagingStore`]). type StagingStore: StagingStore; /// Durable last-known-good cache ([`SnapshotCache`]). diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index bb3ad4978..11d0ff82e 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -42,7 +42,8 @@ use crate::gate::floor; use crate::net::eol::is_expired; use crate::net::fanout_get_verify; use crate::net::fetch_head_block; -use crate::net::publish::{PublishOutcome, PublishReceipt}; +use crate::net::liveness::HeldRecord; +use crate::net::publish::PublishOutcome; use crate::net::record_publish::{ PreflightError, RecordPublishError, RecordPublishRequest, preflight_settings, publish_record, }; @@ -462,6 +463,12 @@ async fn next_revision( /// Seal `settings` and publish them at [`settings_name`] through the shared /// publish port, so the record inherits register-first, seq-CAS, and confirm /// like every other record. +/// +/// Returns the confirmed record as a [`HeldRecord`], so the caller can enrol it +/// in the session's renewal set: the settings record carries a client-signed +/// 90-day EOL and the API republisher is keyless, so a name nobody renews +/// lapses on its own and every device without a cached copy then refuses the +/// placement decision fail-closed. #[allow(clippy::too_many_arguments)] pub async fn publish_settings( transport: &T, @@ -474,7 +481,7 @@ pub async fn publish_settings( orphans: &OrphanHeads, login_secret: &[u8], settings: &VaultSettings, -) -> Result +) -> Result where T: RecordTransport + Clone + 'static, H: Http, @@ -547,7 +554,14 @@ where floor::advance_sequence_on_unseal(floors, &revision_adopted_key(&name), revision) .await .map_err(SettingsPublishError::Floor)?; - Ok(receipt) + Ok(HeldRecord { + routing_key: name.as_str().to_owned(), + record_bytes: receipt.record_bytes, + signer, + head_cid: head.cid().to_owned(), + // The settings record anchors its sealed body and nothing else. + content_cids: Vec::new(), + }) } /// Resolve the vault settings record, bounded by diff --git a/crates/engine/tests/vault_settings.rs b/crates/engine/tests/vault_settings.rs index 062896a83..ebf9f8bcb 100644 --- a/crates/engine/tests/vault_settings.rs +++ b/crates/engine/tests/vault_settings.rs @@ -10,9 +10,10 @@ use core::future::poll_fn; use core::num::NonZeroU64; -use core::task::Poll; +use core::task::{Context, Poll, Waker}; use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; +use std::task::Wake; use cipherbox_core::content::{compute_cid, encode_content_cid_str}; use cipherbox_core::ipns::{IpnsName, IpnsRecord}; @@ -22,16 +23,18 @@ use zeroize::Zeroizing; use cipherbox_engine::api::ApiClient; use cipherbox_engine::content::{ByoIpfsConfig, ByoKind, DAG_ROOT_CODEC, PinMode}; +use cipherbox_engine::net::RE_PUT_INTERVAL; use cipherbox_engine::seams::{ - EndpointId, FloorStore, HttpRequest, HttpResponse, RecordTransport, Scheduler, SeamError, - SeamResult, SnapshotCache, UnixMillis, + BoxedTask, EndpointId, FloorStore, HttpRequest, HttpResponse, RecordTransport, Scheduler, + SeamError, SeamResult, SnapshotCache, UnixMillis, }; use cipherbox_engine::testkit::fakes::VirtualScheduler; -use cipherbox_engine::testkit::{FakeDevice, FakeWorld, SeededEntropy, block_on}; +use cipherbox_engine::testkit::{FakeDevice, FakeSeamTypes, FakeWorld, SeededEntropy, block_on}; use cipherbox_engine::{ - DefaultsReason, Gateway, GatewayConfig, OrphanHeads, ProviderError, RetentionPolicy, - SessionBearer, SettingsLoad, SettingsPublishError, SyncTimingProfile, VaultSettings, - load_settings, publish_settings, settings_name, + ApiBaseUrl, Command, CommandOutcome, ContentProfile, DefaultsReason, Engine, EngineError, + EventStream, Gateway, GatewayConfig, LoginSecret, NodeId, OrphanHeads, ProviderError, + RetentionPolicy, SessionBearer, SettingsLoad, SettingsPublishError, StoragePolicy, + SyncTimingProfile, VaultSettings, WriteTarget, load_settings, publish_settings, settings_name, }; const SECRET: [u8; 32] = [7u8; 32]; @@ -52,6 +55,14 @@ struct Blocks { refuse_register: Arc>, /// Every retire request body, verbatim. retired: Arc>>, + /// Every `PATCH /account/byo` body, verbatim, and the flag the account now + /// carries — the quota probe answers off it, so a reconciliation that + /// landed is not offered again. + byo_patches: Arc>>, + byo_account: Arc>, + /// Answer the head-block upload with an address other than the one the + /// bytes hash to. + echo_other_address: Arc>, } impl Blocks { @@ -69,6 +80,21 @@ impl Blocks { self.retired.lock().expect("lock").clone() } + /// Start the account already flagged BYO, so the first hosted write has a + /// disagreement to reconcile. + fn on_a_byo_account(self) -> Self { + *self.byo_account.lock().expect("lock") = true; + self + } + + fn byo_patches(&self) -> Vec { + self.byo_patches.lock().expect("lock").clone() + } + + fn echo_other_address(&self) { + *self.echo_other_address.lock().expect("lock") = true; + } + /// The one block on the plane, for a fixture that uploaded exactly one. fn only_block(&self) -> String { let store = self.store.lock().expect("lock"); @@ -88,7 +114,12 @@ impl Blocks { if url.ends_with("/content/upload") { let block = request.body.clone().unwrap_or_default(); let size = block.len(); - let cid = self.put(block); + let mut cid = self.put(block.clone()); + if *self.echo_other_address.lock().expect("lock") { + let mut other = block; + other.push(0); + cid = encode_content_cid_str(&compute_cid(DAG_ROOT_CODEC, &other)); + } return ok(format!("{{\"cid\":\"{cid}\",\"size\":{size}}}").into_bytes()); } if url.ends_with("/registry/retire") { @@ -105,6 +136,20 @@ impl Blocks { body: Vec::new(), }); } + if url.ends_with("/account/quota") { + let advisory = *self.byo_account.lock().expect("lock"); + return ok(format!( + "{{\"usedBytes\":0,\"limitBytes\":1000000000,\"advisory\":{advisory}}}" + ) + .into_bytes()); + } + if url.ends_with("/account/byo") { + let body = String::from_utf8(request.body.clone().unwrap_or_default()) + .unwrap_or_else(|_| String::new()); + *self.byo_account.lock().expect("lock") = body.contains("true"); + self.byo_patches.lock().expect("lock").push(body); + return ok(Vec::new()); + } if url.contains("/registry/") { return ok(Vec::new()); } @@ -1786,3 +1831,311 @@ fn a_settings_publish_whose_fan_out_acked_nothing_retires_nothing() { assert!(blocks.retired().is_empty(), "nothing was retired"); assert!(orphans.pending().is_empty(), "nothing is pending either"); } + +// --------------------------------------------------------------------------- +// The facade caller: a host saves settings through `Command::SaveVaultSettings`, +// and the confirmed record joins the session's renewal set. +// --------------------------------------------------------------------------- + +fn engine_on(device: &FakeDevice) -> (Engine, EventStream) { + Engine::new( + device.seam_set(), + Box::new(SeededEntropy::new(21)), + SyncTimingProfile::CI, + ContentProfile::CI, + StoragePolicy::CI, + ApiBaseUrl::offline(), + GatewayConfig::disabled(), + ) +} + +/// Poll every spawned loop until each is parked on a timer again. +fn poll_until_parked(tasks: &mut [BoxedTask]) { + struct Woken(Mutex); + impl Wake for Woken { + fn wake(self: Arc) { + self.wake_by_ref(); + } + fn wake_by_ref(self: &Arc) { + *self.0.lock().expect("lock") = true; + } + } + let flag = Arc::new(Woken(Mutex::new(false))); + let waker = Waker::from(flag.clone()); + let mut cx = Context::from_waker(&waker); + loop { + *flag.0.lock().expect("lock") = false; + for task in tasks.iter_mut() { + let _ = task.as_mut().poll(&mut cx); + } + if !*flag.0.lock().expect("lock") { + return; + } + } +} + +/// A cold-started engine whose block plane is wired for a whole scenario, with +/// its spawned loops parked at their first sleep. +fn boot( + world: &FakeWorld, + device: &FakeDevice, + blocks: &Blocks, +) -> (Engine, EventStream, Vec) { + serve_http(device, blocks, 40); + let (mut engine, events) = engine_on(device); + block_on(engine.start(LoginSecret::new(SECRET.to_vec()))).expect("cold start"); + let mut tasks = world.scheduler.take_spawned_tasks(); + poll_until_parked(&mut tasks); + (engine, events, tasks) +} + +#[test] +fn saving_vault_settings_through_the_facade_publishes_the_record() { + let world = FakeWorld::new(); + let blocks = Blocks::default(); + let device = world.device(b"me"); + let (mut engine, _events, _tasks) = boot(&world, &device, &blocks); + + assert_eq!( + block_on(engine.command(Command::SaveVaultSettings { + settings: configured(), + })), + Ok(CommandOutcome::Done) + ); + + let name = settings_name(&SECRET); + for endpoint in world.record_store.endpoints() { + assert!( + world + .record_store + .record_at(&endpoint, name.as_str()) + .is_some(), + "the settings record published to every endpoint", + ); + } +} + +#[test] +fn a_settings_save_before_start_is_not_started() { + let world = FakeWorld::new(); + let device = world.device(b"me"); + let (mut engine, _events) = engine_on(&device); + + assert_eq!( + block_on(engine.command(Command::SaveVaultSettings { + settings: configured(), + })), + Err(EngineError::NotStarted) + ); +} + +/// A mode with no usable byte destination is refused before anything is sealed +/// — the host must change the settings, so it is never reported as an outage a +/// retry could clear. +#[test] +fn a_settings_save_naming_no_byte_destination_is_refused_as_a_placement() { + let world = FakeWorld::new(); + let blocks = Blocks::default(); + let device = world.device(b"me"); + let (mut engine, _events, _tasks) = boot(&world, &device, &blocks); + + let outcome = block_on(engine.command(Command::SaveVaultSettings { + settings: VaultSettings { + pin_mode: PinMode::External, + byo: None, + retention: RetentionPolicy::KeepAll, + }, + })); + + assert!( + matches!(outcome, Err(EngineError::NoPlacement { .. })), + "got {outcome:?}", + ); + let name = settings_name(&SECRET); + for endpoint in world.record_store.endpoints() { + assert!( + world + .record_store + .record_at(&endpoint, name.as_str()) + .is_none(), + "nothing was published", + ); + } +} + +/// A save must enrol the name in the liveness loop's set — `publish_settings` +/// states why nothing else keeps it alive. +#[test] +fn a_saved_settings_record_is_kept_alive_by_the_liveness_loop() { + let world = FakeWorld::new(); + let blocks = Blocks::default(); + let device = world.device(b"me"); + let (mut engine, _events, mut tasks) = boot(&world, &device, &blocks); + + block_on(engine.command(Command::SaveVaultSettings { + settings: configured(), + })) + .expect("the save publishes"); + + let name = settings_name(&SECRET); + let endpoints = world.record_store.endpoints(); + let published = world + .record_store + .record_at(&endpoints[0], name.as_str()) + .expect("the settings record published"); + + // Clobber what every endpoint serves; only a re-PUT of the held record can + // put the published bytes back. + for endpoint in &endpoints { + world + .record_store + .seed_record(endpoint, name.as_str(), b"not the record".to_vec()); + } + world.scheduler.advance(RE_PUT_INTERVAL); + poll_until_parked(&mut tasks); + + for endpoint in &endpoints { + assert_eq!( + world.record_store.record_at(endpoint, name.as_str()), + Some(published.clone()), + "the hourly pass re-PUT the settings record", + ); + } +} + +/// The renewal set is session state: it dies with the engine rather than +/// outliving it in a parked loop (security rule 7). +#[test] +fn a_dropped_engine_stops_renewing_its_settings_record() { + let world = FakeWorld::new(); + let blocks = Blocks::default(); + let device = world.device(b"me"); + let (mut engine, _events, mut tasks) = boot(&world, &device, &blocks); + + block_on(engine.command(Command::SaveVaultSettings { + settings: configured(), + })) + .expect("the save publishes"); + + let name = settings_name(&SECRET); + let endpoints = world.record_store.endpoints(); + drop(engine); + for endpoint in &endpoints { + world + .record_store + .seed_record(endpoint, name.as_str(), b"not the record".to_vec()); + } + world.scheduler.advance(RE_PUT_INTERVAL); + poll_until_parked(&mut tasks); + + assert_eq!( + world.record_store.record_at(&endpoints[0], name.as_str()), + Some(b"not the record".to_vec()), + "a dropped engine re-PUTs nothing", + ); +} + +/// The held map is refreshed in place by the resolve tick; the settings slot is +/// not, so a renewal that did not check would re-sign this session's body over +/// a second device's newer one — at a winning sequence and a fresh validity. +#[test] +fn a_renewal_never_re_signs_a_settings_record_a_second_device_superseded() { + let world = FakeWorld::new(); + let blocks = Blocks::default(); + let device = world.device(b"me"); + let (mut engine, _events, mut tasks) = boot(&world, &device, &blocks); + + block_on(engine.command(Command::SaveVaultSettings { + settings: configured(), + })) + .expect("the save publishes"); + + // A second device of the account saves after this session did, at the same + // sequence and inside the renewal window. + seed_settings_until( + &device, + &blocks, + b"a-second-devices-body", + 1, + "1970-01-20T00:00:00Z", + ); + let name = settings_name(&SECRET); + let endpoints = world.record_store.endpoints(); + let superseding = world + .record_store + .record_at(&endpoints[0], name.as_str()) + .expect("the second device's record is live"); + + world.scheduler.advance(RE_PUT_INTERVAL); + poll_until_parked(&mut tasks); + + for endpoint in &endpoints { + assert_eq!( + world.record_store.record_at(endpoint, name.as_str()), + Some(superseding.clone()), + "the renewal must not re-sign the superseded body", + ); + } +} + +/// A saved placement is the member's answer to "where do my bytes go", so it +/// has to bind this session and not just the next start — including the +/// account-wide BYO flag the hosted ingress gates on. +#[test] +fn a_saved_placement_binds_the_running_session() { + let world = FakeWorld::new(); + let blocks = Blocks::default().on_a_byo_account(); + let device = world.device(b"me"); + let (mut engine, _events, _tasks) = boot(&world, &device, &blocks); + + // Cold start found no record, so this session places on the hosted leg and + // the first write reconciles the account off its stale BYO flag. + open_and_drop_a_write(&mut engine); + assert_eq!(blocks.byo_patches(), vec![r#"{"byo":false}"#.to_owned()]); + + block_on(engine.command(Command::SaveVaultSettings { + settings: external_only(), + })) + .expect("the save publishes"); + open_and_drop_a_write(&mut engine); + + assert_eq!( + blocks.byo_patches(), + vec![r#"{"byo":false}"#.to_owned(), r#"{"byo":true}"#.to_owned()], + "the saved External placement rebound the session and re-armed the flag", + ); +} + +/// Open a write and abandon it: the quota pre-flight the open runs is what +/// reads the session's placement. +fn open_and_drop_a_write(engine: &mut Engine) { + let handle = block_on(engine.begin_write( + WriteTarget::NewFile { + parent: NodeId([0u8; 16]), + name: "placement-probe.txt".to_owned(), + }, + 4, + )) + .expect("the write opens"); + block_on(engine.abort_write(handle)); +} + +/// The API answering about a block other than the one uploaded is a fail-closed +/// verdict on that answer, never an outage a host should retry. +#[test] +fn a_settings_save_the_api_answered_about_another_block_is_a_trust_violation() { + let world = FakeWorld::new(); + let blocks = Blocks::default(); + let device = world.device(b"me"); + let (mut engine, _events, _tasks) = boot(&world, &device, &blocks); + blocks.echo_other_address(); + + let outcome = block_on(engine.command(Command::SaveVaultSettings { + settings: configured(), + })); + + assert!( + matches!(outcome, Err(EngineError::TrustViolation { .. })), + "got {outcome:?}", + ); +} diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index 8eca6370c..555ca38fe 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -24,6 +24,10 @@ conformance = ["cipherbox-engine/test-kit"] [dependencies] cipherbox-engine.workspace = true wasm-bindgen = "0.2" +# Credentials the boundary carries — the accelerator bearer token and a member's +# BYO provider token — reach the engine in a zeroizing buffer, never a plain +# `String`. +zeroize.workspace = true # Browser wasm host deps: the engine worker host (`src/host.rs`) and the shared # seam adapters (`src/seams_bridge.rs`) marshal JS values, drive engine futures, @@ -40,9 +44,6 @@ getrandom = { version = "0.3", features = ["wasm_js"] } # while `start`/`command` serialize as the single writer; a Mutex bounds # `nextEvent` to one outstanding stream read. async-lock = "3" -# Wraps the accelerator bearer token in a zeroizing buffer before it reaches the -# engine's `GatewaySource` — the credential never sits in a plain `String`. -zeroize.workspace = true # The boundary tests run under wasm-bindgen-test-runner. Core is a test-only # dependency: the contact-code round-trip needs a signed bundle to import, and diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 18be585bb..3c916352f 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -20,9 +20,12 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] -use cipherbox_engine::Contact; +use cipherbox_engine::content::{ByoIpfsConfig as EngineByo, ByoKind as EngineByoKind}; use cipherbox_engine::facade; +use cipherbox_engine::{Contact, PinMode as EnginePinMode, RetentionPolicy}; +use core::num::NonZeroU64; use wasm_bindgen::prelude::*; +use zeroize::Zeroizing; #[cfg(all(target_family = "wasm", target_os = "unknown"))] mod seams_bridge; @@ -143,6 +146,117 @@ impl From for facade::Permission { } } +// --------------------------------------------------------------------------- +// Vault settings — the member's placement, provider and retention choice, as a +// host builds it for `Command.saveVaultSettings`. Write-only across the +// boundary: no getter reads a config back out, so a stored provider credential +// never crosses back into JS. +// --------------------------------------------------------------------------- + +/// Where a version's bytes are pinned. +#[wasm_bindgen] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PinMode { + /// CipherBox's hosted pin store (the cold-start default). + Hosted, + /// The member's own provider only. + External, + /// Both legs. + Dual, +} + +impl From for EnginePinMode { + fn from(mode: PinMode) -> Self { + match mode { + PinMode::Hosted => EnginePinMode::Hosted, + PinMode::External => EnginePinMode::External, + PinMode::Dual => EnginePinMode::Dual, + } + } +} + +/// The kind of member-supplied IPFS provider, which fixes the reachability +/// probe. +#[wasm_bindgen] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ByoKind { + /// A Kubo RPC endpoint. + Kubo, + /// An IPFS Pinning Service API endpoint. + Psa, + /// A Pinata endpoint. + Pinata, +} + +impl From for EngineByoKind { + fn from(kind: ByoKind) -> Self { + match kind { + ByoKind::Kubo => EngineByoKind::Kubo, + ByoKind::Psa => EngineByoKind::Psa, + ByoKind::Pinata => EngineByoKind::Pinata, + } + } +} + +/// A member's own IPFS provider. The engine validates the endpoint and the +/// credential before either reaches a request. +#[wasm_bindgen] +pub struct ByoIpfsConfig { + inner: EngineByo, +} + +#[wasm_bindgen] +impl ByoIpfsConfig { + /// Builds a provider config. `accessToken` is `undefined` for a provider + /// that needs none; when present it lands in a zeroizing buffer. + #[wasm_bindgen(constructor)] + pub fn new(endpoint: String, kind: ByoKind, access_token: Option) -> ByoIpfsConfig { + Self { + inner: EngineByo { + endpoint, + kind: kind.into(), + access_token: access_token.map(Zeroizing::new), + }, + } + } +} + +/// The owner's client configuration, as `Command.saveVaultSettings` seals it +/// into the vault settings record. +#[wasm_bindgen] +pub struct VaultSettings { + inner: cipherbox_engine::VaultSettings, +} + +#[wasm_bindgen] +impl VaultSettings { + /// Builds the settings to publish. `byo` is `undefined` when the member + /// runs no provider of their own; `keepLatestVersions` is `undefined` to + /// keep every version, and `0` is refused rather than read as "keep none", + /// which would retire the live version of every file. + #[wasm_bindgen(constructor)] + pub fn new( + pin_mode: PinMode, + byo: Option, + keep_latest_versions: Option, + ) -> Result { + let retention = match keep_latest_versions { + None => RetentionPolicy::KeepAll, + Some(n) => RetentionPolicy::KeepLatest( + NonZeroU64::new(u64::from(n)) + .ok_or_else(|| JsError::new("keepLatestVersions must be > 0"))?, + ), + }; + Ok(Self { + inner: cipherbox_engine::VaultSettings { + pin_mode: pin_mode.into(), + byo: byo.map(|config| config.inner), + retention, + }, + }) + } +} + /// The staleness ladder (#33 D4): a view is `Fresh`, quietly `Reconciling`, /// `Stale` past the profile threshold, or `Offline`. Availability staleness, /// never a trust violation. @@ -690,6 +804,14 @@ impl Command { }) } + /// Publish the account's vault settings record. + #[wasm_bindgen(js_name = saveVaultSettings)] + pub fn save_vault_settings(settings: VaultSettings) -> Command { + Self::wrap(facade::Command::SaveVaultSettings { + settings: settings.inner, + }) + } + /// Exchange a host-collected SIWE wallet signature (secondary method). #[wasm_bindgen(js_name = siweLogin)] pub fn siwe_login(message: String, signature: Vec) -> Command { diff --git a/crates/wasm/tests/boundary.rs b/crates/wasm/tests/boundary.rs index e6d85af21..09ab6a98c 100644 --- a/crates/wasm/tests/boundary.rs +++ b/crates/wasm/tests/boundary.rs @@ -11,8 +11,8 @@ use cipherbox_engine::facade; use cipherbox_engine::seams::OpId; use cipherbox_wasm::{ - Command, DeadLetterReason, Event, NodeId, NodeKind, OpPhase, PendingClass, Permission, - SnapshotView, Staleness, + ByoIpfsConfig, ByoKind, Command, DeadLetterReason, Event, NodeId, NodeKind, OpPhase, + PendingClass, Permission, PinMode, SnapshotView, Staleness, VaultSettings, }; use js_sys::{Array, BigInt, Reflect, Uint8Array}; use wasm_bindgen::{JsCast, JsValue}; @@ -402,3 +402,37 @@ fn snapshot_view_getters_cross_with_boundary_shapes() { vec![1u8; 16] ); } + +/// The refusal builds a `JsError`, so it is only reachable on this target. +#[wasm_bindgen_test] +fn a_zero_retention_cap_is_refused_rather_than_defaulted() { + assert!( + VaultSettings::new(PinMode::Hosted, None, Some(0)).is_err(), + "0 must not be read as a retention policy" + ); + assert!(VaultSettings::new(PinMode::Hosted, None, Some(1)).is_ok()); + assert!( + VaultSettings::new(PinMode::Hosted, None, None).is_ok(), + "no cap keeps every version" + ); +} + +/// The builder's name is the settings command's whole readable surface. +#[wasm_bindgen_test] +fn a_vault_settings_command_carries_the_stable_builder_name() { + let settings = VaultSettings::new( + PinMode::Dual, + Some(ByoIpfsConfig::new( + "https://kubo.example".to_owned(), + ByoKind::Kubo, + Some("s3cret".to_owned()), + )), + Some(3), + ) + .expect("a positive cap builds"); + + assert_eq!( + Command::save_vault_settings(settings).name(), + "saveVaultSettings" + ); +} diff --git a/packages/client/src/testkit.ts b/packages/client/src/testkit.ts index 2a9540b0e..440503ccc 100644 --- a/packages/client/src/testkit.ts +++ b/packages/client/src/testkit.ts @@ -29,6 +29,8 @@ export const fakeWasmEnums = { NodeKind: { File: 0, Folder: 1 }, PendingClass: { None: 0, Metadata: 1, Content: 2 }, Permission: { Read: 0, Write: 1 }, + PinMode: { Hosted: 0, External: 1, Dual: 2 }, + ByoKind: { Kubo: 0, Psa: 1, Pinata: 2 }, Staleness: { Fresh: 0, Reconciling: 1, Stale: 2, Offline: 3 }, OpPhase: { DownloadStarted: 0, diff --git a/packages/client/src/worker/commandCodec.test.ts b/packages/client/src/worker/commandCodec.test.ts index b3117e842..a3729baa7 100644 --- a/packages/client/src/worker/commandCodec.test.ts +++ b/packages/client/src/worker/commandCodec.test.ts @@ -146,6 +146,145 @@ describe('buildCommand', () => { 'invalid request field opId: number' ); }); + + describe('saveVaultSettings', () => { + /** Records what the settings builders were constructed with. */ + const spyWasm = (): { wasm: EngineWasm; byo: unknown[][]; settings: unknown[][] } => { + const byo: unknown[][] = []; + const settings: unknown[][] = []; + const wasm = { + ...fakeWasmEnums, + ByoIpfsConfig: class { + constructor(...args: unknown[]) { + byo.push(args); + } + }, + VaultSettings: class { + constructor(...args: unknown[]) { + settings.push(args); + } + }, + Command: { saveVaultSettings: (value: unknown) => ({ value }) }, + } as unknown as EngineWasm; + return { wasm, byo, settings }; + }; + + it('carries the provider config and the retention count through', () => { + const { wasm, byo, settings } = spyWasm(); + + buildCommand(wasm, { + kind: 'saveVaultSettings', + settings: { + pinMode: 'dual', + byo: { endpoint: 'https://kubo.example', kind: 'pinata', accessToken: 's3cret' }, + keepLatestVersions: 3, + }, + }); + + expect(byo).toEqual([['https://kubo.example', fakeWasmEnums.ByoKind.Pinata, 's3cret']]); + expect(settings).toHaveLength(1); + expect(settings[0][0]).toBe(fakeWasmEnums.PinMode.Dual); + expect(settings[0][2]).toBe(3); + }); + + it('spells an absent provider and an absent retention cap as undefined', () => { + const { wasm, byo, settings } = spyWasm(); + + buildCommand(wasm, { + kind: 'saveVaultSettings', + settings: { pinMode: 'hosted', byo: null, keepLatestVersions: null }, + }); + + expect(byo).toEqual([]); + expect(settings[0][1]).toBeUndefined(); + expect(settings[0][2]).toBeUndefined(); + }); + + it('spells a null credential as absent, never as the string "null"', () => { + const { wasm, byo } = spyWasm(); + + buildCommand(wasm, { + kind: 'saveVaultSettings', + settings: { + pinMode: 'external', + byo: { endpoint: 'https://kubo.example', kind: 'kubo', accessToken: null }, + keepLatestVersions: null, + }, + }); + + expect(byo).toEqual([['https://kubo.example', fakeWasmEnums.ByoKind.Kubo, undefined]]); + }); + + it('refuses a retention cap past the u32 the builder takes', () => { + const { wasm } = spyWasm(); + + // The number ABI wraps rather than rejects, so 2**32 + 1 would arrive as + // "keep only the newest" — a cap that retires every other version. + expect(() => + buildCommand(wasm, { + kind: 'saveVaultSettings', + settings: { pinMode: 'hosted', byo: null, keepLatestVersions: 2 ** 32 + 1 }, + }) + ).toThrow('invalid request field settings.keepLatestVersions: number'); + }); + + it('refuses a zero retention cap before it builds the provider config', () => { + const { wasm, byo } = spyWasm(); + + // The builder holds a `NonZeroU64`, so zero throws there — after this + // provider config already minted a wasm object holding the token. + expect(() => + buildCommand(wasm, { + kind: 'saveVaultSettings', + settings: { + pinMode: 'hosted', + byo: { endpoint: 'https://kubo.example', kind: 'kubo', accessToken: 's3cret' }, + keepLatestVersions: 0, + }, + }) + ).toThrow('invalid request field settings.keepLatestVersions: number'); + expect(byo).toEqual([]); + }); + + it('refuses before it builds the credential-bearing provider config', () => { + const { wasm, byo } = spyWasm(); + + expect(() => + buildCommand(wasm, { + kind: 'saveVaultSettings', + settings: { + pinMode: 'nowhere', + byo: { endpoint: 'https://kubo.example', kind: 'kubo', accessToken: 's3cret' }, + keepLatestVersions: null, + }, + } as unknown as CommandDescriptor) + ).toThrow('invalid request field settings.pinMode: string'); + expect(byo).toEqual([]); + }); + + it('rejects an unknown pin mode or provider kind rather than defaulting one', () => { + const { wasm } = spyWasm(); + const refusesSettings = + (settings: unknown): (() => unknown) => + () => + buildCommand(wasm, { kind: 'saveVaultSettings', settings } as CommandDescriptor); + + expect( + refusesSettings({ pinMode: 'somewhere-else', byo: null, keepLatestVersions: null }) + ).toThrow('invalid request field settings.pinMode: string'); + expect( + refusesSettings({ + pinMode: 'dual', + byo: { endpoint: 'https://kubo.example', kind: 'ipfs-cluster', accessToken: null }, + keepLatestVersions: null, + }) + ).toThrow('invalid request field settings.byo.kind: string'); + expect(refusesSettings(null)).toThrow('invalid request field settings: null'); + expect(refusesSettings({ pinMode: 'hosted', byo: null, keepLatestVersions: -1 })).toThrow( + 'invalid request field settings.keepLatestVersions: number' + ); + }); + }); }); describe('readEvent', () => { diff --git a/packages/client/src/worker/commandCodec.ts b/packages/client/src/worker/commandCodec.ts index 819433f88..18dc0e2b7 100644 --- a/packages/client/src/worker/commandCodec.ts +++ b/packages/client/src/worker/commandCodec.ts @@ -23,8 +23,10 @@ import type { WasmBlockedOp, WasmCommand, WasmEvent, + WasmByoIpfsConfig, WasmNodeId, WasmSnapshotView, + WasmVaultSettings, } from './engineWasm.js'; /** @@ -104,6 +106,62 @@ function permission(wasm: EngineWasm, value: unknown): number { throw invalidField('permission', value); } +function pinMode(wasm: EngineWasm, value: unknown): number { + if (value === 'hosted') return wasm.PinMode.Hosted; + if (value === 'external') return wasm.PinMode.External; + if (value === 'dual') return wasm.PinMode.Dual; + throw invalidField('settings.pinMode', value); +} + +function byoKind(wasm: EngineWasm, value: unknown): number { + if (value === 'kubo') return wasm.ByoKind.Kubo; + if (value === 'psa') return wasm.ByoKind.Psa; + if (value === 'pinata') return wasm.ByoKind.Pinata; + throw invalidField('settings.byo.kind', value); +} + +/** + * A retention cap. Distinct from [`count`]: the builder takes a `u32` and the + * JS→wasm number ABI *wraps* rather than rejects, so an over-range value would + * arrive as an unrelated small cap — `2**32 + 1` as "keep only the newest". + * + * Zero is refused here rather than left to the `NonZeroU64` the builder holds: + * the refusal would otherwise land after `byoConfig` minted a wasm object + * holding the access token, stranding that allocation with no owner to free it. + */ +function retentionCap(value: unknown, field: string): number { + const cap = count(value, field); + if (cap === 0 || cap > 0xffff_ffff) throw invalidField(field, value); + return cap; +} + +function byoConfig(wasm: EngineWasm, value: unknown): WasmByoIpfsConfig { + const config = record(value, 'settings.byo'); + const endpoint = text(config.endpoint, 'settings.byo.endpoint'); + const kind = byoKind(wasm, config.kind); + const token = config.accessToken ?? undefined; + return new wasm.ByoIpfsConfig( + endpoint, + kind, + token === undefined ? undefined : text(token, 'settings.byo.accessToken') + ); +} + +/** + * Every scalar is checked before the first wasm object is built: a `new` that a + * later refusal abandons strands its allocation — and the credential inside it + * — in linear memory until the finalization registry runs. + */ +function vaultSettings(wasm: EngineWasm, value: unknown): WasmVaultSettings { + const settings = record(value, 'settings'); + const mode = pinMode(wasm, settings.pinMode); + const rawKeep = settings.keepLatestVersions ?? undefined; + const keep = + rawKeep === undefined ? undefined : retentionCap(rawKeep, 'settings.keepLatestVersions'); + const byo = settings.byo ?? undefined; + return new wasm.VaultSettings(mode, byo === undefined ? undefined : byoConfig(wasm, byo), keep); +} + /** * Exhaustiveness bound: adding a command kind without a builder fails the * build, and a sender off the union gets a refusal rather than the `undefined` @@ -172,6 +230,8 @@ export function buildCommand(wasm: EngineWasm, descriptor: CommandDescriptor): W return wasm.Command.acceptShare(bytes(descriptor.sealedSharePointer, 'sealedSharePointer')); case 'rotateNow': return wasm.Command.rotateNow(nodeId(wasm, descriptor.node, 'node')); + case 'saveVaultSettings': + return wasm.Command.saveVaultSettings(vaultSettings(wasm, descriptor.settings)); case 'siweLogin': return wasm.Command.siweLogin( text(descriptor.message, 'message'), diff --git a/packages/client/src/worker/engineWasm.ts b/packages/client/src/worker/engineWasm.ts index ae44fc18b..e2b5f22e3 100644 --- a/packages/client/src/worker/engineWasm.ts +++ b/packages/client/src/worker/engineWasm.ts @@ -17,6 +17,12 @@ export type WasmNodeId = object; /** Opaque wasm-bindgen `Command` handle. */ export type WasmCommand = object; +/** Opaque wasm-bindgen `ByoIpfsConfig` handle. */ +export type WasmByoIpfsConfig = object; + +/** Opaque wasm-bindgen `VaultSettings` handle. */ +export type WasmVaultSettings = object; + /** wasm-bindgen `Event` — key-free view state; a getter is `undefined` off-variant. */ export interface WasmEvent { readonly kind: string; @@ -133,9 +139,16 @@ export interface EngineWasm { createInviteLink(node: WasmNodeId, permission: number): WasmCommand; acceptShare(sealedSharePointer: Uint8Array): WasmCommand; rotateNow(node: WasmNodeId): WasmCommand; + saveVaultSettings(settings: WasmVaultSettings): WasmCommand; siweLogin(message: string, signature: Uint8Array): WasmCommand; logout(): WasmCommand; }; + ByoIpfsConfig: new (endpoint: string, kind: number, accessToken?: string) => WasmByoIpfsConfig; + VaultSettings: new ( + pinMode: number, + byo?: WasmByoIpfsConfig, + keepLatestVersions?: number + ) => WasmVaultSettings; NodeKind: { readonly File: number; readonly Folder: number }; PendingClass: { readonly None: number; @@ -143,6 +156,8 @@ export interface EngineWasm { readonly Content: number; }; Permission: { readonly Read: number; readonly Write: number }; + PinMode: { readonly Hosted: number; readonly External: number; readonly Dual: number }; + ByoKind: { readonly Kubo: number; readonly Psa: number; readonly Pinata: number }; OpPhase: { readonly DownloadStarted: number; readonly DownloadCompleted: number; diff --git a/packages/client/src/worker/protocol.ts b/packages/client/src/worker/protocol.ts index 1a70a9d92..d3983a0a1 100644 --- a/packages/client/src/worker/protocol.ts +++ b/packages/client/src/worker/protocol.ts @@ -116,6 +116,28 @@ export interface SnapshotDescriptor { staleness: Staleness; } +/** Where a version's bytes are pinned (mirrors the facade `PinMode`). */ +export type PinMode = 'hosted' | 'external' | 'dual'; + +/** The kind of member-supplied IPFS provider (mirrors the facade `ByoKind`). */ +export type ByoKind = 'kubo' | 'psa' | 'pinata'; + +/** A member's own IPFS provider, as data. */ +export interface ByoIpfsConfigDescriptor { + endpoint: string; + kind: ByoKind; + /** `null` for a provider that needs no credential. */ + accessToken: string | null; +} + +/** The member's placement, provider and retention choice, as data. */ +export interface VaultSettingsDescriptor { + pinMode: PinMode; + byo: ByoIpfsConfigDescriptor | null; + /** Newest-n retention; `null` keeps every version within quota. */ + keepLatestVersions: number | null; +} + /** * One write intent, as data. Each variant's `kind` matches the facade command * builder name (`crates/wasm` `Command`), so the worker maps it mechanically. @@ -140,6 +162,7 @@ export type CommandDescriptor = | { kind: 'createInviteLink'; node: Uint8Array; permission: Permission } | { kind: 'acceptShare'; sealedSharePointer: Uint8Array } | { kind: 'rotateNow'; node: Uint8Array } + | { kind: 'saveVaultSettings'; settings: VaultSettingsDescriptor } | { kind: 'siweLogin'; message: string; signature: Uint8Array } | { kind: 'logout' };