diff --git a/pkgs/id/src/blob_gate.rs b/pkgs/id/src/blob_gate.rs index e64b3bdd3..8c2ba7a31 100644 --- a/pkgs/id/src/blob_gate.rs +++ b/pkgs/id/src/blob_gate.rs @@ -17,12 +17,15 @@ //! is one whose verdict cannot be delivered. //! //! The reference index and the membership query are behind [`WorldAccess`]. [`FoldedWorlds`] -//! answers membership from the folded worlds. Its reference index has no producer yet, so -//! [`blob_events`] is not wired into `id serve`. +//! answers membership from the folded worlds. The index has a producer now: +//! [`build_reference_index`] over the event store's document references (see +//! `EventStore::document_references`), with [`fold_group_events`] folding the group log for +//! membership, and [`RefreshingWorlds`] swapping fresh folds in as they change. `id serve` +//! wires [`blob_events`] with those pieces whenever it has the node's event store open. use std::{ - collections::{BTreeSet, HashMap}, - sync::Arc, + collections::{BTreeMap, BTreeSet, HashMap}, + sync::{Arc, RwLock}, }; use iroh_base::EndpointId; @@ -112,10 +115,11 @@ pub fn decide( /// Membership is the world's own authority, as [`Worlds::holds_here`] gives it: a member of its /// group, or a grant in its own chains. A crossing from another world does not count. /// -/// The reference index maps a blob hash to the worlds that name it. Nothing in the store writes -/// it yet. Artifacts carry SHA-256 hashes, not BLAKE3 blob hashes, and only WASM modules are -/// pinned, without a record of their world. So the index is passed in, and a hash it does not -/// hold is unreferenced, which refuses it to every requester. +/// The reference index maps a blob hash to the worlds that name it. It is built by +/// [`build_reference_index`] from the event store's document references; artifacts are not a +/// source, because they carry SHA-256 hashes, not BLAKE3 blob hashes, and only WASM modules +/// are pinned, without a record of their world. A hash the index does not hold is +/// unreferenced, which refuses it to every requester. #[derive(Debug)] pub struct FoldedWorlds { worlds: Worlds, @@ -143,6 +147,140 @@ impl WorldAccess for FoldedWorlds { } } +/// The world key a group is indexed under: the hex of its create event's hash, which is the +/// group hash every later event of the group names. A document event's `group` extension is +/// the same hash, so document references and membership folds agree on the key. +pub fn world_key(group: &p2panda_core::Hash) -> String { + group.to_string() +} + +/// Folds group-log events into the [`Worlds`] the gate reads membership from. +/// +/// Events are partitioned by world: a create event opens the world keyed by its own hash +/// (a friendship's create names its derived group instead), and every other event joins +/// the world its `group` names. An event that names no group and creates none belongs to +/// no world and is skipped. +/// +/// # Errors +/// +/// Any error [`Worlds::fold`] gives for one world. +pub fn fold_group_events( + events: &[crate::groups::Event], +) -> Result { + let mut by_world: BTreeMap> = BTreeMap::new(); + for event in events { + let key = match (&event.body, &event.group) { + (_, Some(group)) => world_key(group), + (crate::groups::Body::Create(_), None) => world_key(&event.id), + _ => continue, + }; + by_world.entry(key).or_default().push(event.clone()); + } + Worlds::fold(&by_world, BTreeMap::new()) +} + +/// The child hashes a blob's bytes name when the blob is a `HashSeq`: the sequence is the +/// raw concatenation of 32-byte hashes, so any other length is not a sequence and names no +/// children. +pub fn hashseq_children(bytes: &[u8]) -> Vec { + if bytes.is_empty() || !bytes.len().is_multiple_of(32) { + return Vec::new(); + } + bytes + .chunks_exact(32) + .map(|chunk| { + let mut raw = [0u8; 32]; + raw.copy_from_slice(chunk); + Hash::from_bytes(raw) + }) + .collect() +} + +/// Builds the reference index the gate decides from: blob hash → the worlds that name it. +/// +/// `references` pairs each referenced blob with the world that names it (a document event's +/// blob and its group, as [`world_key`] spells it). A document with no group names no +/// world: the caller leaves it out, and its blob stays unreferenced. +/// +/// `expand` names the children of a blob that is a `HashSeq` collection. Children are +/// attributed to every world that references the collection, transitively: a member of +/// the world may fetch the collection's contents, not just its sequence blob. The caller +/// decides what counts as a collection (serve expands only a blob whose parsed children +/// are all present in its store, so arbitrary 32-byte-multiple content does not spray +/// references onto hashes nobody stored). +pub fn build_reference_index( + references: impl IntoIterator, + expand: impl Fn(&Hash) -> Vec, +) -> HashMap> { + let mut index: HashMap> = HashMap::new(); + for (hash, world) in references { + index.entry(hash).or_default().insert(world); + } + // Attribute HashSeq children to the referencing worlds, transitively. The worklist + // holds (hash, world) pairs not yet expanded for that world, so the loop ends when + // every reachable pair has been expanded once. + let mut worklist: Vec<(Hash, String)> = index + .iter() + .flat_map(|(hash, worlds)| worlds.iter().map(move |world| (*hash, world.clone()))) + .collect(); + let mut expanded: BTreeSet<(Hash, String)> = BTreeSet::new(); + while let Some((hash, world)) = worklist.pop() { + if !expanded.insert((hash, world.clone())) { + continue; + } + for child in expand(&hash) { + let worlds = index.entry(child).or_default(); + if worlds.insert(world.clone()) { + worklist.push((child, world.clone())); + } + } + } + index +} + +/// [`WorldAccess`] whose folded worlds a producer can replace as they change. +/// +/// `blob_events` holds one `Arc` for the life of the server; the fold behind it must not +/// be the snapshot from startup, or a removed member would keep fetching and a new +/// document would stay unreferenced until a restart. The producer re-folds and calls +/// [`RefreshingWorlds::refresh`]; each decision then reads the latest fold. A poisoned +/// lock fails closed, like every other gate refusal. +#[derive(Debug)] +pub struct RefreshingWorlds { + inner: RwLock, +} + +impl RefreshingWorlds { + /// Starts from an initial fold. + pub const fn new(initial: FoldedWorlds) -> Self { + Self { + inner: RwLock::new(initial), + } + } + + /// Replaces the folded worlds every later decision reads. + pub fn refresh(&self, next: FoldedWorlds) { + if let Ok(mut current) = self.inner.write() { + *current = next; + } + } +} + +impl WorldAccess for RefreshingWorlds { + fn is_current_member(&self, world: &str, requester: &EndpointId) -> bool { + self.inner + .read() + .is_ok_and(|folded| folded.is_current_member(world, requester)) + } + + fn worlds_referencing(&self, hash: &Hash) -> Vec { + self.inner + .read() + .map(|folded| folded.worlds_referencing(hash)) + .unwrap_or_default() + } +} + /// The verdict for a request from `peer`: allowed only when the peer is known and `allow` /// says so. An unknown peer is refused. fn admit( @@ -512,3 +650,130 @@ mod tests { } } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod producer_tests { + use std::collections::{BTreeMap, HashMap}; + + use iroh_base::SecretKey; + use p2panda_core::Hash as PandaHash; + + use super::*; + use crate::groups::policy::Policy; + use crate::groups::{Body, Create, Event, MemberOp, Template}; + + fn id(n: u8) -> EndpointId { + SecretKey::from_bytes(&[n; 32]).public() + } + + fn blob(n: u8) -> Hash { + Hash::from_bytes([n; 32]) + } + + #[test] + fn reference_index_attributes_hashseq_children_transitively() { + // world "w" references the sequence blob 1; 1 names 2 and 3; 2 names 4. + let children: HashMap> = + HashMap::from([(blob(1), vec![blob(2), blob(3)]), (blob(2), vec![blob(4)])]); + let index = build_reference_index([(blob(1), "w".to_owned())], |hash| { + children.get(hash).cloned().unwrap_or_default() + }); + for hash in [blob(1), blob(2), blob(3), blob(4)] { + assert_eq!( + index.get(&hash).map(|w| w.contains("w")), + Some(true), + "{hash} must be referenced by w" + ); + } + assert!(!index.contains_key(&blob(9)), "unrelated hash stays out"); + } + + #[test] + fn hashseq_children_parses_concatenated_hashes() { + let mut bytes = blob(5).as_bytes().to_vec(); + bytes.extend_from_slice(blob(6).as_bytes()); + assert_eq!(hashseq_children(&bytes), vec![blob(5), blob(6)]); + assert!(hashseq_children(&bytes[..33]).is_empty(), "ragged length"); + assert!(hashseq_children(&[]).is_empty(), "empty names nothing"); + } + + /// A group log for one world: founder creates it, adds `member`, then removes them + /// when `removed`. Returns the events and the world key the fold gives them. + fn group_events(member: EndpointId, removed: bool) -> (Vec, String) { + let founder = *id(1).as_bytes(); + let member_key = *member.as_bytes(); + let create = PandaHash::digest(b"gate-create"); + let add = PandaHash::digest(b"gate-add"); + let mut events = vec![ + Event { + id: create, + author: founder, + after: Vec::new(), + group: None, + body: Body::Create(Create { + template: Template::Custom, + policy: Policy::default(), + members: Vec::new(), + admins_on_create: false, + }), + }, + Event { + id: add, + author: founder, + after: vec![create], + group: Some(create), + body: Body::Member(MemberOp::Add { + member: member_key, + role: crate::groups::Role::Read, + }), + }, + ]; + if removed { + events.push(Event { + id: PandaHash::digest(b"gate-remove"), + author: founder, + after: vec![add], + group: Some(create), + body: Body::Member(MemberOp::Remove { + member: member_key, + successor: None, + }), + }); + } + (events, world_key(&create)) + } + + #[test] + fn folded_group_log_gates_fetch_and_refresh_revokes_it() { + let member = id(2); + let (events, world) = group_events(member, false); + let worlds = fold_group_events(&events).expect("the log folds"); + let index = build_reference_index([(blob(7), world.clone())], |_| Vec::new()); + let gate = RefreshingWorlds::new(FoldedWorlds::new(worlds, index)); + let policy = BlobPolicy::default(); + assert!( + decide(&gate, &policy, &member, &blob(7), Request::Fetch), + "a current member fetches a referenced blob" + ); + assert!( + !decide(&gate, &policy, &id(3), &blob(7), Request::Fetch), + "a non-member is refused" + ); + assert!( + !decide(&gate, &policy, &member, &blob(8), Request::Fetch), + "an unreferenced blob is refused even to a member" + ); + + // The member is removed; a refreshed fold must revoke the fetch at once. + let (events, world) = group_events(member, true); + let worlds = fold_group_events(&events).expect("the log folds"); + let index = build_reference_index([(blob(7), world)], |_| Vec::new()); + gate.refresh(FoldedWorlds::new(worlds, index)); + assert!( + !decide(&gate, &policy, &member, &blob(7), Request::Fetch), + "a removed member loses fetch on the next request" + ); + let _ = BTreeMap::::new(); + } +} diff --git a/pkgs/id/src/commands/serve.rs b/pkgs/id/src/commands/serve.rs index 1497a5fb9..655f9cd80 100644 --- a/pkgs/id/src/commands/serve.rs +++ b/pkgs/id/src/commands/serve.rs @@ -725,6 +725,82 @@ fn serve_groups_log_path(path: Option) -> PathBuf { crate::commands::group::log_path(path) } +/// Builds the blob gate's folded worlds from the two sources serve has: the group +/// log at `groups_log` for membership, and the event store's document references +/// for which blobs each world names (#173). +/// +/// A referenced blob that parses as a `HashSeq` contributes its children to the +/// same world, but only when every child is present in the store — arbitrary +/// content whose length is a multiple of 32 bytes would otherwise spray +/// references onto hashes nobody stored. Any source that fails to read folds to +/// empty for that source: the gate fails closed, and the next refresh retries. +#[cfg(feature = "web")] +async fn build_blob_gate( + events: &Arc>, + store: &iroh_blobs::api::Store, + groups_log: &std::path::Path, +) -> crate::blob_gate::FoldedWorlds { + let empty_worlds = crate::groups::worlds::Worlds::empty; + let worlds = match GroupLog::open(groups_log) { + Ok(log) => crate::blob_gate::fold_group_events(log.events()).unwrap_or_else(|error| { + warn!("blob gate: group log does not fold: {error}"); + empty_worlds() + }), + Err(error) => { + warn!("blob gate: cannot open group log: {error}"); + empty_worlds() + } + }; + let references = { + let events = events.lock().await; + match events.document_references() { + Ok(references) => references, + Err(error) => { + warn!("blob gate: cannot read document references: {error}"); + Vec::new() + } + } + }; + // Expand HashSeq collections whose children are all in the store. + let mut children: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut frontier: Vec = references.iter().map(|(hash, _)| *hash).collect(); + while let Some(hash) = frontier.pop() { + if !seen.insert(hash) { + continue; + } + let Ok(bytes) = store.get_bytes(hash).await else { + continue; + }; + let kids = crate::blob_gate::hashseq_children(&bytes); + if kids.is_empty() { + continue; + } + let mut all_present = true; + for kid in &kids { + match store.has(*kid).await { + Ok(true) => {} + _ => { + all_present = false; + break; + } + } + } + if all_present { + frontier.extend(kids.iter().copied()); + children.insert(hash, kids); + } + } + let named = references + .into_iter() + .filter_map(|(hash, group)| group.map(|group| (hash, crate::blob_gate::world_key(&group)))); + let index = crate::blob_gate::build_reference_index(named, |hash| { + children.get(hash).cloned().unwrap_or_default() + }); + crate::blob_gate::FoldedWorlds::new(worlds, index) +} + pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { let ServeOptions { ephemeral, @@ -846,22 +922,6 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { Err(e) => warn!("tags: legacy metadata import failed: {e:#}"), } - // Build router — gossip ALPN is always registered (needed by iroh-docs), - // but peer discovery gossip topic only joins when gossip is enabled - let meta = MetaProtocol::new( - &store_handle, - Some(peer_discovery.clone()), - Arc::clone(&tag_store), - access.clone(), - node_id, - ); - // iroh-blobs rejects pushes unless an event handler enables them; this one - // allows them only from nodes the access policy lets write. - let blobs = BlobsProtocol::new( - &store_handle, - Some(crate::access::blobs_events(access.clone())), - ); - // The node's own event store, shared between the web layer and /id-range-sync/1 // range sync (#792). Serve opens it once, on the same directory the web // state would open (the `events` sibling of the identity database), and @@ -884,6 +944,61 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { None }; + // The blob membership gate (#173). With the event store open, serve builds the + // gate from the group log (membership) and the store's document references + // (which blobs each world names), and a refresh task below keeps it current. + // Without the store there is no document source to index, so blob reads stay + // ungated this run — push-only, as before — and serve says so at startup. + #[cfg(feature = "web")] + let blob_gate: Option> = match &event_events { + Some(events) => { + let folded = build_blob_gate( + events, + &store_handle, + &serve_groups_log_path(groups_log.clone()), + ) + .await; + Some(Arc::new(crate::blob_gate::RefreshingWorlds::new(folded))) + } + None => None, + }; + #[cfg(not(feature = "web"))] + let blob_gate: Option> = None; + if blob_gate.is_none() { + warn!( + "blob gate (#173): inactive — the event store is only opened with the web UI, \ + so blob reads are not membership-checked this run" + ); + } + + // Build router — gossip ALPN is always registered (needed by iroh-docs), + // but peer discovery gossip topic only joins when gossip is enabled + let meta = MetaProtocol::new( + &store_handle, + Some(peer_discovery.clone()), + Arc::clone(&tag_store), + access.clone(), + node_id, + ); + // iroh-blobs rejects pushes unless an event handler enables them; this one + // allows them only from nodes the access policy lets write. With the blob + // gate built, the gate's handler decides pushes the same way and also + // membership-checks fetches and observes (#173). + let blobs = match &blob_gate { + Some(gate) => BlobsProtocol::new( + &store_handle, + Some(crate::blob_gate::blob_events( + Arc::clone(gate), + crate::blob_gate::BlobPolicy::default(), + access.clone(), + )), + ), + None => BlobsProtocol::new( + &store_handle, + Some(crate::access::blobs_events(access.clone())), + ), + }; + // Every world lives in one hub, shared by the Iroh protocol and the web // bridge. The default world opens now so a bad journal fails startup. #[cfg(feature = "world")] @@ -1314,6 +1429,8 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { for (_, warning) in evaluate_group_warnings(&mut group_watcher, &groups_log, unix_now()) { status!("group warning: {}", warning.message()); } + #[cfg(feature = "web")] + let gate_groups_log = groups_log.clone(); let group_warning_handle = tokio::spawn(async move { let mut interval = tokio::time::interval(GROUP_WARNING_INTERVAL); interval.tick().await; @@ -1323,6 +1440,29 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { } }); + // Blob gate refresh (#173): on the same cadence as the group warnings, + // re-fold the group log and the document references so a removed member + // loses fetch and a new document becomes fetchable without a restart. + #[cfg(feature = "web")] + let blob_gate_handle = match (&blob_gate, &event_events) { + (Some(gate), Some(events)) => { + let gate = Arc::clone(gate); + let events = Arc::clone(events); + let store = store_handle.clone(); + let groups_log = gate_groups_log; + Some(tokio::spawn(async move { + let mut interval = tokio::time::interval(GROUP_WARNING_INTERVAL); + interval.tick().await; + loop { + interval.tick().await; + let folded = build_blob_gate(&events, &store, &groups_log).await; + gate.refresh(folded); + } + })) + } + _ => None, + }; + tokio::signal::ctrl_c().await?; remove_serve_lock().await?; router.shutdown().await?; @@ -1350,6 +1490,11 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { if let Some(hub) = &world_hub { hub.shutdown_all().await; } + #[cfg(feature = "web")] + if let Some(gate_task) = blob_gate_handle { + gate_task.abort(); + let _ = gate_task.await; + } group_warning_handle.abort(); let _ = group_warning_handle.await; store.shutdown().await?; @@ -1689,10 +1834,7 @@ mod tests { // used, unchanged. let custom = PathBuf::from("shared").join("relocated.jsonl"); assert_eq!(serve_groups_log_path(Some(custom.clone())), custom); - assert_eq!( - serve_groups_log_path(None), - PathBuf::from(GROUPS_FILE_NAME) - ); + assert_eq!(serve_groups_log_path(None), PathBuf::from(GROUPS_FILE_NAME)); } #[test] @@ -1939,7 +2081,10 @@ mod tests { // outright. Either way the assertion holds. #[cfg(unix)] { - assert!(is_process_alive(1), "pid 1 exists and must read as alive (EPERM is not death)"); + assert!( + is_process_alive(1), + "pid 1 exists and must read as alive (EPERM is not death)" + ); } } diff --git a/pkgs/id/src/event_store/documents.rs b/pkgs/id/src/event_store/documents.rs index f0a8a3571..9aeafa2bb 100644 --- a/pkgs/id/src/event_store/documents.rs +++ b/pkgs/id/src/event_store/documents.rs @@ -84,4 +84,47 @@ impl EventStore { records.sort_by_key(|record| *record.id.as_bytes()); Ok(records) } + + /// The blob references the gate index is built from: for every applied `document.put` + /// its author has not removed, the document's blob hash and the group the event names + /// (`None` when the event names none — such a document references its blob for no + /// world). Sorted by blob hash for a stable index build. + /// + /// # Errors + /// + /// Returns a redb error if the read fails, or [`StoreError::Corrupt`] for a stored + /// document event that carries no document, or a stored removal that names no document. + pub fn document_references(&self) -> Result)>, StoreError> { + let records = self.documents()?; + let removed: std::collections::BTreeSet<[u8; 32]> = records + .iter() + .filter(|record| record.removal.is_some()) + .map(|record| *record.id.as_bytes()) + .collect(); + let txn = self.db.begin_read()?; + let events = txn.open_table(EVENTS)?; + let applied_table = txn.open_table(APPLIED)?; + let mut references = Vec::new(); + for row in events.iter()? { + let (_position, bytes) = row?; + let event = decode_stored(bytes.value())?; + if removed.contains(event.id.as_bytes()) { + continue; + } + if applied_table.get(event.id.as_bytes().as_slice())?.is_none() { + continue; + } + if event.header.extensions.kind != DOCUMENT_KIND { + continue; + } + let Some(document) = &event.header.extensions.document else { + return Err(corrupt( + "a stored document event carries no document".to_owned(), + )); + }; + references.push((document.blob_hash(), event.header.extensions.group)); + } + references.sort_by_key(|(hash, _)| *hash.as_bytes()); + Ok(references) + } } diff --git a/pkgs/id/src/groups/worlds.rs b/pkgs/id/src/groups/worlds.rs index 669bb14fc..69aa6b988 100644 --- a/pkgs/id/src/groups/worlds.rs +++ b/pkgs/id/src/groups/worlds.rs @@ -79,6 +79,15 @@ pub struct Worlds { } impl Worlds { + /// No worlds at all: the fold of nothing, for callers that must fail closed + /// (an empty gate) rather than propagate a fold error. + pub fn empty() -> Self { + Self { + folds: BTreeMap::new(), + markers: BTreeMap::new(), + } + } + /// Folds each world's events on their own. /// /// # Errors diff --git a/pkgs/id/src/local.rs b/pkgs/id/src/local.rs index d40c1f521..5a51577ab 100644 --- a/pkgs/id/src/local.rs +++ b/pkgs/id/src/local.rs @@ -31,6 +31,7 @@ use iroh_docs::protocol::Docs; use iroh_gossip::net::Gossip; use crate::access::AccessPolicy; +use crate::blob_gate::{BlobPolicy, RefreshingWorlds}; use crate::fileops::FileOps; use crate::protocol::{MetaProtocol, MetaRequest, MetaResponse}; use crate::store::{StoreType, load_or_create_keypair, open_store}; @@ -50,6 +51,8 @@ pub struct LocalNode { endpoint: Endpoint, meta: Arc, node_id: EndpointId, + /// The blob membership gate the embedder supplied, if any (#173). + blob_gate: Option<(Arc, BlobPolicy)>, } impl LocalNode { @@ -142,9 +145,21 @@ impl LocalNode { endpoint, meta, node_id, + blob_gate: None, }) } + /// Supplies the blob membership gate [`LocalNode::serve`] enforces (#173). + /// + /// `LocalNode` holds no group log or event store of its own, so the embedder — + /// which owns the worlds and documents — builds the gate (see + /// [`crate::blob_gate::RefreshingWorlds`]) and keeps it refreshed. Without a + /// gate, `serve` keeps its historical behavior: pushes are policy-checked and + /// reads are open. + pub fn set_blob_gate(&mut self, gate: Arc, policy: BlobPolicy) { + self.blob_gate = Some((gate, policy)); + } + /// Handle to the blob store (cheap to clone). pub fn blobs(&self) -> Store { self.blobs.clone() @@ -198,7 +213,19 @@ impl LocalNode { access.clone(), self.node_id, ); - let blobs = BlobsProtocol::new(&self.blobs, Some(blobs_events(access))); + // With a gate, blob fetches and observes are membership-checked (#173) and + // pushes keep this policy; without one, pushes alone are gated, as before. + let blobs = match &self.blob_gate { + Some((gate, policy)) => BlobsProtocol::new( + &self.blobs, + Some(crate::blob_gate::blob_events( + Arc::clone(gate), + policy.clone(), + access, + )), + ), + None => BlobsProtocol::new(&self.blobs, Some(blobs_events(access))), + }; Router::builder(self.endpoint.clone()) .accept(META_ALPN, meta) .accept(BLOBS_ALPN, blobs) diff --git a/pkgs/id/src/local_events.rs b/pkgs/id/src/local_events.rs index 846b9e671..c72ec77d6 100644 --- a/pkgs/id/src/local_events.rs +++ b/pkgs/id/src/local_events.rs @@ -23,7 +23,7 @@ use p2panda_core::{Hash, SigningKey}; use crate::chain::{ChainExt, Event, MAX_AFTER, sign}; use crate::documents::{DOCUMENT_KIND, DocumentRef, REMOVAL_KIND}; -use crate::event_store::{EventStore, Ingest}; +use crate::event_store::{EventStore, Ingest, StoreError}; use crate::search::{Hit, ValueMatch, search_store}; use crate::tag_claims::{CLEAR_KIND, Claim, MAX_CLEAR_CLAIMS, SET_KIND, TagOp, check_op}; use crate::waiting::{AuthorKey, WaitingLimits}; @@ -151,6 +151,18 @@ impl LocalEvents { /// # Errors /// /// Fails if the store cannot be opened, for example while another process has it open. + /// The blob references in this node's event store, for the blob gate's index + /// (`EventStore::document_references`). + /// + /// # Errors + /// + /// Returns a store error if the read fails. + pub fn document_references( + &self, + ) -> Result)>, StoreError> { + self.store.document_references() + } + pub fn open(dir: &Path, key: SigningKey) -> Result { std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; let store = EventStore::open(dir, WaitingLimits::default(), now_ms())?;