Skip to content

Commit 82c5ee1

Browse files
committed
fix(app): fetch relay KeyPackages before every invitation
1 parent a2f8fd5 commit 82c5ee1

11 files changed

Lines changed: 321 additions & 180 deletions

File tree

crates/marmot-app/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,11 @@ separate cache reads, membership, provider response, profile hydration, and netw
7979
queries or identities.
8080

8181
Group creation and invites still take pubkeys at the action boundary. The app canonicalizes and deduplicates the
82-
requested roster, reuses current cached KeyPackages, and resolves cold members in bounded multi-author relay batches
83-
before building the MLS add. Hosts may prewarm that same bounded composition lookup without reserving packages or
84-
durably admitting strangers; the final mutation revalidates every package. New Nostr-routed groups generate
82+
requested roster and fetches current KeyPackages in bounded multi-author relay batches before building the MLS add.
83+
Cached packages remain useful for discovery, but cannot authorize an invitation or substitute for a failed relay
84+
lookup. Hosts may prewarm that same bounded composition lookup without reserving packages or durably admitting
85+
strangers; the final action reuses discovery routes but fetches packages again before the mutation validates them.
86+
New Nostr-routed groups generate
8587
`marmot.transport.nostr.routing.v1` at creation, store the component bytes in
8688
signed MLS app data, and project the decoded `nostr_group_id` plus relay list into group subscriptions and publish
8789
targets.

crates/marmot-app/src/client/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,9 +1183,9 @@ impl AppClient {
11831183
Ok(self.runtime.publish_fresh_key_package().await?)
11841184
}
11851185

1186-
/// Resolve and cache the current composition roster without reserving or
1187-
/// consuming any KeyPackage. Group creation revalidates the cached bytes
1188-
/// and the MLS mutation boundary retains its ordinary validation.
1186+
/// Fetch current relay KeyPackages for the composition roster without
1187+
/// reserving or consuming them. Group creation fetches again before the
1188+
/// MLS mutation; cached packages only inform discovery.
11891189
pub async fn prewarm_group_member_key_packages(
11901190
&self,
11911191
member_refs: &[&str],

crates/marmot-app/src/directory/member_key_packages.rs

Lines changed: 69 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
//!
33
//! The create and invite paths know the whole requested roster before they
44
//! mutate MLS state. Resolve that set as a set: canonicalize aliases, collapse
5-
//! duplicate account ids, reuse validated local/directory entries, and batch
6-
//! cold relay work by compatible endpoint set. The legacy one-member resolver
7-
//! remains the bounded fallback for relay/query shapes that do not support a
8-
//! multi-author request.
5+
//! duplicate account ids, reuse discovery routes, and fetch current KeyPackages
6+
//! from relays before every invitation. Cached packages are discovery hints, not
7+
//! evidence that a recipient still owns the private bundle. Unsupported batch
8+
//! queries fall back to bounded single-author relay requests.
99
1010
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
1111
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -22,7 +22,7 @@ use transport_nostr_adapter::{
2222

2323
use crate::key_package_records::{
2424
fresh_or_cached_key_package, fresh_relay_list_status_from_records,
25-
latest_fresh_key_package_from_records, merge_relay_list_status, validated_cached_key_package,
25+
latest_fresh_key_package_from_records, merge_relay_list_status,
2626
};
2727
use crate::relay_plane::{DirectoryEventQuery, DirectoryFetchOutcome};
2828
use crate::{AccountRelayListStatus, AppError, FetchedKeyPackage, MarmotApp};
@@ -82,8 +82,22 @@ mod tests {
8282
assert!(!cache.order.contains(&newest));
8383
}
8484

85+
#[test]
86+
fn package_refresh_does_not_renew_routes_that_expired_during_fetch() {
87+
let mut cache = MemberKeyPackagePrewarmCache::default();
88+
let id = "01".repeat(32);
89+
cache.insert(fetched(id.clone()));
90+
cache.entries.get_mut(&id).unwrap().inserted_at =
91+
Instant::now() - MEMBER_PREWARM_CACHE_TTL - Duration::from_secs(1);
92+
cache.insert(fetched(id.clone()));
93+
assert!(
94+
cache.get(&id).is_none(),
95+
"the next lookup must refresh expired routes"
96+
);
97+
}
98+
8599
#[tokio::test]
86-
async fn composition_prewarm_reuse_preserves_original_freshness_deadline() {
100+
async fn composition_prewarm_route_reuse_preserves_original_freshness_deadline() {
87101
let (_directory, app, accounts, _fetcher) =
88102
crate::tests::member_resolution_fixture(1, false).await;
89103
let id = &accounts[0].account_id_hex;
@@ -102,6 +116,9 @@ mod tests {
102116
app.prewarm_group_member_key_packages(&[id.as_str()])
103117
.await
104118
.unwrap();
119+
app.resolve_member_key_packages(&[id.as_str()])
120+
.await
121+
.unwrap();
105122
}
106123
let mut cache = app.member_key_package_prewarm_cache.lock().unwrap();
107124
assert_eq!(cache.entries.get(id).unwrap().inserted_at, original);
@@ -126,15 +143,23 @@ impl MemberKeyPackagePrewarmCache {
126143
}
127144

128145
fn insert(&mut self, fetched: FetchedKeyPackage) {
129-
self.remove_expired();
130146
let account_id_hex = fetched.account_id_hex.clone();
147+
// Refreshing package bytes does not prove that reused relay routes are
148+
// fresh. Preserve their original deadline until the entry expires and
149+
// the resolver performs discovery again.
150+
let inserted_at = self
151+
.entries
152+
.get(&account_id_hex)
153+
.map(|entry| entry.inserted_at)
154+
.unwrap_or_else(Instant::now);
155+
self.remove_expired();
131156
self.order.retain(|existing| existing != &account_id_hex);
132157
self.order.push_back(account_id_hex.clone());
133158
self.entries.insert(
134159
account_id_hex,
135160
MemberKeyPackagePrewarmEntry {
136161
fetched,
137-
inserted_at: Instant::now(),
162+
inserted_at,
138163
},
139164
);
140165
while self.entries.len() > MEMBER_PREWARM_CACHE_LIMIT {
@@ -176,8 +201,8 @@ pub struct MemberKeyPackagePrewarmSummary {
176201
pub requested_members: u64,
177202
/// Canonical account ids after aliases and duplicates are collapsed.
178203
pub unique_members: u64,
179-
/// Packages satisfied by validated local state, durable directory state,
180-
/// or the process-local prewarm cache.
204+
/// Retained for API compatibility; always zero because packages must be
205+
/// fetched from relays. Discovery routes may still be reused.
181206
pub reused_members: u64,
182207
/// Packages that required relay resolution during this call.
183208
pub network_resolved_members: u64,
@@ -205,7 +230,6 @@ impl From<MemberKeyPackageResolutionStats> for MemberKeyPackagePrewarmSummary {
205230
#[derive(Clone)]
206231
struct MemberTarget {
207232
account_id_hex: String,
208-
local_label: Option<String>,
209233
relay_lists: AccountRelayListStatus,
210234
cached_relay_lists: AccountRelayListStatus,
211235
relay_records: Vec<crate::relay_plane::DirectoryRelayEventRecord>,
@@ -226,9 +250,10 @@ impl MarmotApp {
226250
/// Resolve a roster as one deterministic set.
227251
///
228252
/// Account aliases are canonicalized and duplicate account ids collapse to
229-
/// their first input position. On error, the error belonging to the first
230-
/// unresolved canonical member is returned even if later relay work
231-
/// completed earlier.
253+
/// their first input position. Every package is fetched from relays; cached
254+
/// packages are never a fallback if that lookup fails. On error, the error
255+
/// belonging to the first unresolved canonical member is returned even if
256+
/// later relay work completed earlier.
232257
pub async fn resolve_member_key_packages(
233258
&self,
234259
member_refs: &[&str],
@@ -263,9 +288,9 @@ impl MarmotApp {
263288
/// The roster must also resolve a safe Marmot inbox route for every member;
264289
/// missing routes return [`AppError::MissingMemberInboxRoute`]. Successfully
265290
/// fetched packages and relay metadata remain cached even when another
266-
/// member fails readiness. A later create call re-reads and validates the
267-
/// cached bytes, and the MLS mutation boundary still performs its ordinary
268-
/// lifetime/single-use validation.
291+
/// member fails readiness. A later create call can reuse discovery routes,
292+
/// but fetches KeyPackages again because prewarmed material may have been
293+
/// consumed in the meantime.
269294
pub async fn prewarm_group_member_key_packages(
270295
&self,
271296
member_refs: &[&str],
@@ -351,7 +376,6 @@ impl MarmotApp {
351376
.unwrap_or_else(AccountRelayListStatus::empty);
352377
targets.push(MemberTarget {
353378
account_id_hex,
354-
local_label: local.map(|account| account.label),
355379
relay_lists: relay_lists.clone(),
356380
cached_relay_lists: relay_lists,
357381
relay_records: Vec::new(),
@@ -361,82 +385,44 @@ impl MarmotApp {
361385
let mut outcomes = (0..targets.len())
362386
.map(|_| None)
363387
.collect::<Vec<Option<Result<KeyPackage, AppError>>>>();
364-
let mut unresolved = Vec::new();
388+
// Even a valid, unexpired cached package may have been consumed on
389+
// another device. Every purpose, including composition prewarm, must
390+
// fetch current relay publications; only discovery routes are reused.
391+
let unresolved = (0..targets.len()).collect::<Vec<_>>();
365392
let mut fresh_prewarmed_routes = HashSet::new();
366-
let mut reused_members = 0usize;
393+
let reused_members = 0;
367394
for (index, target) in targets.iter_mut().enumerate() {
395+
// Recovery deliberately refreshes routes as well as packages.
368396
if purpose == MemberResolutionPurpose::CommitFresh {
369-
unresolved.push(index);
370-
continue;
371-
}
372-
if let Some(label) = &target.local_label
373-
&& let Some(key_package) = self.validated_current_local_key_package(label)
374-
&& let Ok(key_package) =
375-
self.validate_member_key_package_current(&target.account_id_hex, key_package)
376-
{
377-
outcomes[index] = Some(Ok(key_package));
378-
reused_members += 1;
379-
continue;
380-
}
381-
let cached = self.directory_entry_for_account_id(&target.account_id_hex)?;
382-
if let Some(key_package) = cached.and_then(|entry| entry.key_package)
383-
&& let Ok(key_package) =
384-
validated_cached_key_package(&target.account_id_hex, &key_package)
385-
&& let Ok(key_package) =
386-
self.validate_member_key_package_current(&target.account_id_hex, key_package)
387-
{
388-
outcomes[index] = Some(Ok(key_package));
389-
reused_members += 1;
390397
continue;
391398
}
392399
let prefetched = self
393400
.member_key_package_prewarm_cache
394401
.lock()
395402
.unwrap_or_else(|poisoned| poisoned.into_inner())
396403
.get(&target.account_id_hex);
397-
if let Some(mut fetched) = prefetched {
398-
target.relay_lists = merge_relay_list_status(
399-
target.relay_lists.clone(),
400-
fetched.relay_lists.clone(),
401-
);
404+
if let Some(fetched) = prefetched {
405+
target.relay_lists =
406+
merge_relay_list_status(target.relay_lists.clone(), fetched.relay_lists);
402407
target.cached_relay_lists = target.relay_lists.clone();
403-
fetched.relay_lists = target.relay_lists.clone();
404-
// Reuse must not restart the observation TTL. Only newly
405-
// fetched packages enter through accept_prefetched_key_package.
406-
let accepted = self.validate_member_key_package_current(
407-
&fetched.account_id_hex,
408-
fetched.key_package.clone(),
409-
);
410-
let accepted = accepted.and_then(|key_package| {
411-
if purpose == MemberResolutionPurpose::Commit {
412-
self.remember_directory_key_package(&fetched)?;
413-
}
414-
Ok(key_package)
415-
});
416-
if let Ok(key_package) = accepted {
417-
if !target.relay_lists.nip65.relays.is_empty()
418-
&& !self
419-
.retain_safe_discovered_endpoints(
420-
target
421-
.relay_lists
422-
.inbox
423-
.relays
424-
.iter()
425-
.cloned()
426-
.map(TransportEndpoint)
427-
.collect(),
428-
"member prewarm inbox readiness",
429-
)
430-
.is_empty()
431-
{
432-
fresh_prewarmed_routes.insert(index);
433-
}
434-
outcomes[index] = Some(Ok(key_package));
435-
reused_members += 1;
436-
continue;
408+
if !target.relay_lists.nip65.relays.is_empty()
409+
&& !self
410+
.retain_safe_discovered_endpoints(
411+
target
412+
.relay_lists
413+
.inbox
414+
.relays
415+
.iter()
416+
.cloned()
417+
.map(TransportEndpoint)
418+
.collect(),
419+
"member prewarm inbox readiness",
420+
)
421+
.is_empty()
422+
{
423+
fresh_prewarmed_routes.insert(index);
437424
}
438425
}
439-
unresolved.push(index);
440426
}
441427

442428
// Durable projections have unknown observation age and need a bounded
@@ -916,19 +902,12 @@ impl MarmotApp {
916902
.filter(|record| record.event.pubkey == *account_id)
917903
.cloned()
918904
.collect::<Vec<_>>();
919-
let cached = if purpose == MemberResolutionPurpose::CommitFresh {
920-
None
921-
} else {
922-
self.directory_entry_for_account_id(account_id)
923-
.ok()
924-
.flatten()
925-
};
926905
let selected = latest_fresh_key_package_from_records(
927906
account_id,
928907
account_records,
929908
self.directory_freshness(),
930909
)
931-
.and_then(|selection| fresh_or_cached_key_package(account_id, selection, cached));
910+
.and_then(|selection| fresh_or_cached_key_package(account_id, selection, None));
932911
match selected {
933912
Ok(mut fetched) => {
934913
fetched.relay_lists = targets[index].relay_lists.clone();
@@ -982,19 +961,14 @@ impl MarmotApp {
982961
.map_err(|error| {
983962
AppError::RelayDirectory(format!("fetch key packages: {error}"))
984963
})?;
985-
let cached = if purpose == MemberResolutionPurpose::CommitFresh {
986-
None
987-
} else {
988-
app.directory_entry_for_account_id(&target.account_id_hex)?
989-
};
990964
let mut fetched = fresh_or_cached_key_package(
991965
&target.account_id_hex,
992966
latest_fresh_key_package_from_records(
993967
&target.account_id_hex,
994968
records,
995969
app.directory_freshness(),
996970
)?,
997-
cached,
971+
None,
998972
)?;
999973
fetched.relay_lists = target.relay_lists;
1000974
Ok::<_, AppError>(fetched)

crates/marmot-app/src/key_package_records.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ fn cached_key_package_from_entry(
197197
}))
198198
}
199199

200+
#[cfg(test)]
200201
pub(crate) fn validated_cached_key_package(
201202
account_id_hex: &str,
202203
key_package: &DirectoryKeyPackage,

crates/marmot-app/src/lib.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5899,10 +5899,14 @@ impl MarmotApp {
58995899
}
59005900

59015901
#[cfg(test)]
5902-
fn with_test_relay_client(mut self, client: Arc<dyn NostrRelayClient>) -> Self {
5903-
self.relay_plane = MarmotRelayPlane::new_with_loopback(
5902+
fn with_test_relay_client<C>(mut self, client: Arc<C>) -> Self
5903+
where
5904+
C: NostrRelayClient + crate::relay_plane::DirectoryRelayFetcher + 'static,
5905+
{
5906+
self.relay_plane = MarmotRelayPlane::new_with_directory_fetcher_for_test(
59045907
None,
59055908
client.clone(),
5909+
client.clone(),
59065910
self.config.allow_loopback_relay_endpoints,
59075911
);
59085912
self.test_relay_client = Some(client);

crates/marmot-app/src/relay_plane/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,16 +596,18 @@ impl MarmotRelayPlane {
596596

597597
#[cfg(test)]
598598
pub(crate) fn new_with_directory_fetcher_for_test(
599+
subscription_rebuild_lookback: Option<Duration>,
599600
relay_client: Arc<dyn NostrRelayClient>,
600601
directory_fetcher: Arc<dyn DirectoryRelayFetcher>,
602+
allow_loopback: bool,
601603
) -> Self {
602604
Self::from_adapter(
603-
Some(Duration::from_secs(120)),
605+
subscription_rebuild_lookback,
604606
NostrTransportAdapter::new(relay_client),
605607
None,
606608
None,
607609
directory_fetcher,
608-
false,
610+
allow_loopback,
609611
)
610612
}
611613

crates/marmot-app/src/relay_plane/tests.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,16 @@ async fn notification_recovery_closes_account_delivery_and_signals_directory_reb
371371
.expect("inbound recovery must not take the outbound publisher down");
372372
}
373373

374+
#[async_trait::async_trait]
375+
impl DirectoryRelayFetcher for RecordingRelayClient {
376+
async fn fetch_directory_events(
377+
&self,
378+
_request: DirectoryFetchRequest,
379+
) -> Result<Vec<DirectoryRelayEventRecord>, String> {
380+
Ok(Vec::new())
381+
}
382+
}
383+
374384
#[tokio::test]
375385
async fn managed_account_worker_reopens_transport_after_notification_recovery() {
376386
let dir = tempfile::tempdir().unwrap();

crates/marmot-app/src/runtime/onboarding/tests.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,8 +483,12 @@ fn options() -> OnboardingOptions {
483483
fn runtime(path: &std::path::Path, network: Arc<Network>) -> MarmotAppRuntime {
484484
let mut app = MarmotApp::with_relay(path, "wss://default.example")
485485
.with_test_relay_client(network.clone());
486-
app.relay_plane =
487-
MarmotRelayPlane::new_with_directory_fetcher_for_test(network.clone(), network);
486+
app.relay_plane = MarmotRelayPlane::new_with_directory_fetcher_for_test(
487+
Some(Duration::from_secs(120)),
488+
network.clone(),
489+
network,
490+
false,
491+
);
488492
MarmotAppRuntime::new(app)
489493
}
490494
async fn fixture() -> (

0 commit comments

Comments
 (0)