diff --git a/.changeset/multi-space-session.md b/.changeset/multi-space-session.md new file mode 100644 index 0000000..cf187ef --- /dev/null +++ b/.changeset/multi-space-session.md @@ -0,0 +1,5 @@ +--- +"tinycloud-sdk-wasm": minor +--- + +Add multi-space session support. SessionConfig accepts optional additionalSpaces so a single SIWE signature covers multiple spaces. diff --git a/src/routes/mod.rs b/src/routes/mod.rs index efde227..1fb71ef 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -20,7 +20,7 @@ use tinycloud_core::{ storage::{ImmutableReadStore, ImmutableStaging}, types::Resource, util::{DelegationInfo, InvocationInfo}, - InvocationOutcome, TxError, TxStoreError, + InvocationOutcome, TransactResult, TxError, TxStoreError, }; pub mod public; @@ -79,12 +79,19 @@ pub async fn open_host_key( }) } +#[derive(Serialize)] +pub struct DelegateResponse { + pub cid: String, + pub activated: Vec, + pub skipped: Vec, +} + #[post("/delegate")] pub async fn delegate( d: AuthHeaderGetter, req_span: TracingSpan, tinycloud: &State, -) -> Result { +) -> Result, (Status, String)> { let action_label = "delegation"; let span = info_span!(parent: &req_span.0, "delegate", action = %action_label); // Instrumenting async block to handle yielding properly @@ -105,13 +112,37 @@ pub async fn delegate( e.to_string(), ) }) - .and_then(|c| { - c.into_iter() + .and_then(|result: TransactResult| { + let activated: Vec = result + .commits + .keys() + .map(|s| s.to_string()) + .collect(); + let skipped: Vec = result + .skipped_spaces + .iter() + .map(|s| s.to_string()) + .collect(); + + // Get CID from the first committed event, or fall back to + // the delegation CID when all spaces were skipped + let cid = result + .commits + .into_values() .next() - .and_then(|(_, c)| c.committed_events.into_iter().next()) - .ok_or_else(|| (Status::Unauthorized, "Delegation not committed".to_string())) - }) - .map(|h| h.to_cid(0x55).to_string()); + .and_then(|c| c.committed_events.into_iter().next()) + .or_else(|| result.delegation_cids.into_iter().next()) + .map(|h| h.to_cid(0x55).to_string()) + .ok_or_else(|| { + (Status::Unauthorized, "Delegation not committed".to_string()) + })?; + + Ok(Json(DelegateResponse { + cid, + activated, + skipped, + })) + }); timer.observe_duration(); res } diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 6f58b7f..244613d 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -18,7 +18,7 @@ use sea_orm::{ ConnectionTrait, DatabaseTransaction, TransactionTrait, }; use sea_orm_migration::MigratorTrait; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tinycloud_lib::{ authorization::{EncodingError, TinyCloudDelegation}, resource::{Path, SpaceId}, @@ -39,6 +39,15 @@ pub struct Commit { pub consumed_epochs: Vec, } +#[derive(Debug, Clone)] +pub struct TransactResult { + pub commits: HashMap, + pub skipped_spaces: Vec, + /// CIDs of delegations that were processed (saved) regardless of space existence. + /// Used to return a CID even when all spaces were skipped. + pub delegation_cids: Vec, +} + #[non_exhaustive] #[derive(Debug, thiserror::Error)] pub enum TxError { @@ -196,23 +205,23 @@ where async fn transact( &self, events: Vec, - ) -> Result, TxError> { + ) -> Result> { let tx = self .conn .begin_with_config(Some(sea_orm::IsolationLevel::ReadUncommitted), None) .await?; - let commit = transact(&tx, &self.storage, &self.secrets, events).await?; + let result = transact(&tx, &self.storage, &self.secrets, events).await?; tx.commit().await?; - Ok(commit) + Ok(result) } pub async fn delegate( &self, delegation: Delegation, - ) -> Result, TxError> { + ) -> Result> { self.transact(vec![Event::Delegation(Box::new(delegation))]) .await } @@ -220,7 +229,7 @@ where pub async fn revoke( &self, revocation: Revocation, - ) -> Result, TxError> { + ) -> Result> { self.transact(vec![Event::Revocation(Box::new(revocation))]) .await } @@ -231,7 +240,7 @@ where mut inputs: InvocationInputs, ) -> Result< ( - HashMap, + TransactResult, Vec>, ), TxStoreError, @@ -488,7 +497,7 @@ pub(crate) async fn transact( store_setup: &S, secrets: &K, events: Vec, -) -> Result, TxError> { +) -> Result> { // for each event, get the hash and the relevent space(s) let event_hashes = events .into_iter() @@ -540,184 +549,288 @@ pub(crate) async fn transact( }; } - // get max sequence for each of the spaces - let mut max_seqs = event_order::Entity::find() - .filter(event_order::Column::Space.is_in(event_spaces.keys().cloned().map(SpaceIdWrap))) - .select_only() - .column(event_order::Column::Space) - .column_as(event_order::Column::Seq.max(), "max_seq") - .group_by(event_order::Column::Space) - .into_tuple::<(SpaceIdWrap, i64)>() - .all(db) - .await? - .into_iter() - .fold(HashMap::new(), |mut m, (space, seq)| { - m.insert(space, seq + 1); - m - }); - - // get 'most recent' epochs for each of the spaces - let mut most_recent = epoch::Entity::find() - .select_only() - .left_join(epoch_order::Entity) - .filter( - Condition::all() - .add(epoch::Column::Space.is_in(event_spaces.keys().cloned().map(SpaceIdWrap))) - .add(epoch_order::Column::Child.is_null()), - ) - .column(epoch::Column::Space) - .column(epoch::Column::Id) - .into_tuple::<(SpaceIdWrap, Hash)>() - .all(db) - .await? - .into_iter() - .fold( - HashMap::new(), - |mut m: HashMap>, (space, epoch)| { - m.entry(space).or_default().push(epoch); + // For delegation-only transactions, skip spaces that don't exist yet + // instead of failing with SpaceNotFound + let is_delegation_only = event_hashes + .iter() + .all(|(_, e)| matches!(e, Event::Delegation(_))); + + let (event_spaces, skipped_spaces) = if is_delegation_only { + let new_space_ids: HashSet = new_spaces.iter().map(|s| s.0.clone()).collect(); + // Spaces that were just created via new_spaces are definitely existing + let all_space_ids: Vec = event_spaces + .keys() + .filter(|s| !new_space_ids.contains(s)) + .cloned() + .map(SpaceIdWrap) + .collect(); + + let existing: HashSet = if all_space_ids.is_empty() { + HashSet::new() + } else { + space::Entity::find() + .filter(space::Column::Id.is_in(all_space_ids)) + .all(db) + .await? + .into_iter() + .map(|s| s.id.0) + .collect() + }; + + // new_spaces are always existing (just inserted above) + let existing: HashSet = existing + .into_iter() + .chain(new_space_ids) + .collect(); + + let skipped: Vec = event_spaces + .keys() + .filter(|s| !existing.contains(s)) + .cloned() + .collect(); + + let filtered: HashMap<_, _> = event_spaces + .into_iter() + .filter(|(s, _)| existing.contains(s)) + .collect(); + + (filtered, skipped) + } else { + (event_spaces, vec![]) + }; + + // If all spaces were filtered out, we still process delegations below + // but skip epoch/event ordering creation + if !event_spaces.is_empty() { + // get max sequence for each of the spaces + let mut max_seqs = event_order::Entity::find() + .filter( + event_order::Column::Space + .is_in(event_spaces.keys().cloned().map(SpaceIdWrap)), + ) + .select_only() + .column(event_order::Column::Space) + .column_as(event_order::Column::Seq.max(), "max_seq") + .group_by(event_order::Column::Space) + .into_tuple::<(SpaceIdWrap, i64)>() + .all(db) + .await? + .into_iter() + .fold(HashMap::new(), |mut m, (space, seq)| { + m.insert(space, seq + 1); m - }, - ); + }); - // get all the orderings and associated data - let (epoch_order, space_order, event_order, epochs) = event_spaces - .into_iter() - .map(|(space, events)| { - let parents = most_recent.remove(&space).unwrap_or_default(); - let epoch = epoch_hash(&space, &events, &parents)?; - let seq = max_seqs.remove(&space).unwrap_or(0); - Ok((space, (epoch, events, seq, parents))) - }) - .collect::, HashError>>()? - .into_iter() - .map(|(space, (epoch, hashes, seq, parents))| { - ( - parents - .iter() - .map(|parent| epoch_order::Model { - parent: *parent, - child: epoch, - space: space.clone().into(), - }) - .map(epoch_order::ActiveModel::from) - .collect::>(), + // get 'most recent' epochs for each of the spaces + let mut most_recent = epoch::Entity::find() + .select_only() + .left_join(epoch_order::Entity) + .filter( + Condition::all() + .add( + epoch::Column::Space + .is_in(event_spaces.keys().cloned().map(SpaceIdWrap)), + ) + .add(epoch_order::Column::Child.is_null()), + ) + .column(epoch::Column::Space) + .column(epoch::Column::Id) + .into_tuple::<(SpaceIdWrap, Hash)>() + .all(db) + .await? + .into_iter() + .fold( + HashMap::new(), + |mut m: HashMap>, (space, epoch)| { + m.entry(space).or_default().push(epoch); + m + }, + ); + + // get all the orderings and associated data + let (epoch_order, space_order, event_order, epochs) = event_spaces + .into_iter() + .map(|(space, events)| { + let parents = most_recent.remove(&space).unwrap_or_default(); + let epoch = epoch_hash(&space, &events, &parents)?; + let seq = max_seqs.remove(&space).unwrap_or(0); + Ok((space, (epoch, events, seq, parents))) + }) + .collect::, HashError>>()? + .into_iter() + .map(|(space, (epoch, hashes, seq, parents))| { ( - space.clone(), + parents + .iter() + .map(|parent| epoch_order::Model { + parent: *parent, + child: epoch, + space: space.clone().into(), + }) + .map(epoch_order::ActiveModel::from) + .collect::>(), ( - seq, - epoch, - parents, - hashes - .iter() - .enumerate() - .map(|(i, (h, _))| (*h, i as i64)) - .collect::>(), + space.clone(), + ( + seq, + epoch, + parents, + hashes + .iter() + .enumerate() + .map(|(i, (h, _))| (*h, i as i64)) + .collect::>(), + ), ), - ), - hashes - .into_iter() - .enumerate() - .map(|(es, (hash, _))| event_order::Model { - event: *hash, - space: space.clone().into(), + hashes + .into_iter() + .enumerate() + .map(|(es, (hash, _))| event_order::Model { + event: *hash, + space: space.clone().into(), + seq, + epoch, + epoch_seq: es as i64, + }) + .map(event_order::ActiveModel::from) + .collect::>(), + epoch::Model { seq, - epoch, - epoch_seq: es as i64, - }) - .map(event_order::ActiveModel::from) - .collect::>(), - epoch::Model { - seq, - id: epoch, - space: space.into(), + id: epoch, + space: space.into(), + }, + ) + }) + .fold( + ( + Vec::::new(), + HashMap::, HashMap)>::new(), + Vec::::new(), + Vec::::new(), + ), + |(mut eo, mut so, mut ev, mut ep), (eo2, order, ev2, ep2)| { + eo.extend(eo2); + ev.extend(ev2); + so.insert(order.0, order.1); + ep.push(ep2.into()); + (eo, so, ev, ep) }, - ) - }) - .fold( - ( - Vec::::new(), - HashMap::, HashMap)>::new(), - Vec::::new(), - Vec::::new(), - ), - |(mut eo, mut so, mut ev, mut ep), (eo2, order, ev2, ep2)| { - eo.extend(eo2); - ev.extend(ev2); - so.insert(order.0, order.1); - ep.push(ep2.into()); - (eo, so, ev, ep) - }, - ); - - // save epochs - epoch::Entity::insert_many(epochs) - .exec(db) - .await - .map_err(|e| match e { - DbErr::Exec(RuntimeErr::SqlxError(SqlxError::Database(_))) => TxError::SpaceNotFound, - _ => e.into(), - })?; - - // save epoch orderings - if !epoch_order.is_empty() { - epoch_order::Entity::insert_many(epoch_order) + ); + + // save epochs + epoch::Entity::insert_many(epochs) + .exec(db) + .await + .map_err(|e| match e { + DbErr::Exec(RuntimeErr::SqlxError(SqlxError::Database(_))) => { + TxError::SpaceNotFound + } + _ => e.into(), + })?; + + // save epoch orderings + if !epoch_order.is_empty() { + epoch_order::Entity::insert_many(epoch_order) + .exec(db) + .await?; + } + + // save event orderings + event_order::Entity::insert_many(event_order) .exec(db) .await?; - } - // save event orderings - event_order::Entity::insert_many(event_order) - .exec(db) - .await?; + let mut delegation_cids = Vec::new(); + for (hash, event) in event_hashes { + match event { + Event::Delegation(d) => { + let cid = delegation::process(db, *d).await?; + delegation_cids.push(cid); + } + Event::Invocation(i, ops) => { + invocation::process( + db, + *i, + ops.into_iter() + .map(|op| { + let v = space_order + .get(op.space()) + .and_then(|(s, e, _, h)| Some((s, e, h.get(&hash)?))) + .unwrap(); + op.version(*v.0, *v.1, *v.2) + }) + .collect(), + ) + .await?; + } + Event::Revocation(r) => { + revocation::process(db, *r).await?; + } + }; + } - for (hash, event) in event_hashes { - match event { - Event::Delegation(d) => delegation::process(db, *d).await?, - Event::Invocation(i, ops) => { - invocation::process( - db, - *i, - ops.into_iter() - .map(|op| { - let v = space_order - .get(op.space()) - .and_then(|(s, e, _, h)| Some((s, e, h.get(&hash)?))) - .unwrap(); - op.version(*v.0, *v.1, *v.2) - }) - .collect(), - ) - .await? - } - Event::Revocation(r) => revocation::process(db, *r).await?, - }; - } + for space in new_spaces { + store_setup + .create(&space.0) + .await + .map_err(TxError::StoreSetup)?; + secrets + .save_keypair(&space.0) + .await + .map_err(TxError::Secrets)?; + } - for space in new_spaces { - store_setup - .create(&space.0) - .await - .map_err(TxError::StoreSetup)?; - secrets - .save_keypair(&space.0) - .await - .map_err(TxError::Secrets)?; - } + Ok(TransactResult { + commits: space_order + .into_iter() + .map(|(o, (seq, rev, consumed_epochs, h))| { + ( + o, + Commit { + seq, + rev, + consumed_epochs, + committed_events: h.keys().cloned().collect(), + }, + ) + }) + .collect(), + skipped_spaces, + delegation_cids, + }) + } else { + // All spaces were skipped (delegation-only with no existing spaces) + // Still process delegation events to save the delegation records + let mut delegation_cids = Vec::new(); + for (_, event) in event_hashes { + match event { + Event::Delegation(d) => { + let cid = delegation::process(db, *d).await?; + delegation_cids.push(cid); + } + Event::Invocation(_, _) | Event::Revocation(_) => { + unreachable!("non-delegation events with empty event_spaces") + } + }; + } - Ok(space_order - .into_iter() - .map(|(o, (seq, rev, consumed_epochs, h))| { - ( - o, - Commit { - seq, - rev, - consumed_epochs, - committed_events: h.keys().cloned().collect(), - }, - ) + for space in new_spaces { + store_setup + .create(&space.0) + .await + .map_err(TxError::StoreSetup)?; + secrets + .save_keypair(&space.0) + .await + .map_err(TxError::Secrets)?; + } + + Ok(TransactResult { + commits: HashMap::new(), + skipped_spaces, + delegation_cids, }) - .collect()) + } } async fn list( diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index a0f15dc..41856a6 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -11,7 +11,7 @@ pub mod storage; pub mod types; pub mod util; -pub use db::{Commit, InvocationOutcome, SpaceDatabase, TxError, TxStoreError}; +pub use db::{Commit, InvocationOutcome, SpaceDatabase, TransactResult, TxError, TxStoreError}; pub use libp2p; pub use sea_orm; pub use sea_orm_migration; diff --git a/tinycloud-sdk-wasm/src/session.rs b/tinycloud-sdk-wasm/src/session.rs index 9e4db24..8b269ac 100644 --- a/tinycloud-sdk-wasm/src/session.rs +++ b/tinycloud-sdk-wasm/src/session.rs @@ -51,6 +51,11 @@ pub struct SessionConfig { /// Format: "tinycloud:pkh:eip155:{chainId}:{address}:{name}" /// Not to be confused with ReCap ability namespaces (action categories like "kv"). pub space_id: SpaceId, + /// Additional spaces to include in this session's capabilities. + /// Key is a logical name (e.g., "public"), value is the SpaceId. + /// All additional spaces receive the same abilities as the primary space. + #[serde(default)] + pub additional_spaces: Option>, #[serde_as(as = "Option")] #[serde(default)] pub not_before: Option, @@ -74,6 +79,8 @@ pub struct SessionConfig { pub struct PreparedSession { pub jwk: JWK, pub space_id: SpaceId, + #[serde(default)] + pub additional_spaces: Option>, #[serde_as(as = "DisplayFromStr")] pub siwe: Message, pub verification_method: String, @@ -100,35 +107,47 @@ pub struct Session { /// The TinyCloud user space (data container) that this session is bound to. /// Not to be confused with ReCap ability namespaces (action categories like "kv"). pub space_id: SpaceId, + #[serde(default)] + pub additional_spaces: Option>, pub verification_method: String, } impl SessionConfig { fn into_message(self, delegate: &str) -> Result { use serde_json::Value; - self.abilities + + // Collect all spaces: primary + additional + let mut all_spaces = vec![self.space_id.clone()]; + if let Some(ref additional) = self.additional_spaces { + for space_id in additional.values() { + all_spaces.push(space_id.clone()); + } + } + + // Clone abilities since we iterate per space + let abilities = self.abilities.clone(); + + all_spaces .into_iter() - .fold( - Capability::::default(), - |caps, (service, actions)| { - actions.into_iter().fold(caps, |mut caps, (path, action)| { - // Empty path means wildcard - use None to allow any path to extend + .fold(Capability::::default(), |caps, space_id| { + abilities.iter().fold(caps, |caps, (service, actions)| { + actions.iter().fold(caps, |mut caps, (path, action)| { let path_opt = if path.as_str().is_empty() { None } else { - Some(path) + Some(path.clone()) }; caps.with_actions( - self.space_id + space_id .clone() .to_resource(service.clone(), path_opt, None, None) .as_uri(), - action.into_iter().map(|a| (a, [])), + action.iter().map(|a| (a.clone(), [])), ); caps }) - }, - ) + }) + }) .with_proofs(match &self.parents { Some(p) => p.as_slice(), None => &[], @@ -332,6 +351,7 @@ pub fn prepare_session(config: SessionConfig) -> Result }; let space_id = config.space_id.clone(); + let additional_spaces = config.additional_spaces.clone(); let siwe = config .into_message(&verification_method) @@ -339,6 +359,7 @@ pub fn prepare_session(config: SessionConfig) -> Result Ok(PreparedSession { space_id, + additional_spaces, jwk, verification_method, siwe, @@ -364,6 +385,7 @@ pub fn complete_session_setup(signed_session: SignedSession) -> Result