Skip to content

Commit 0a5d517

Browse files
committed
fix(relay): admit delayed immutable Desktop profiles
Signed-off-by: Logan Johnson <loganj@squareup.com>
1 parent f38a070 commit 0a5d517

2 files changed

Lines changed: 137 additions & 3 deletions

File tree

crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,94 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() {
165165
}
166166
}
167167
}
168+
169+
#[tokio::test]
170+
#[ignore = "requires Postgres"]
171+
async fn aged_desktop_profile_retries_through_production_ingest_without_resigning() {
172+
let mut state = bridge_handler_test_state()
173+
.await
174+
.expect("test infrastructure");
175+
Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true;
176+
let host = format!("desktop-retry-{}.example", uuid::Uuid::new_v4().simple());
177+
state.db.ensure_configured_community(&host).await.unwrap();
178+
let owner = Keys::generate();
179+
let outsider = Keys::generate();
180+
let profile = buzz_core::desktop_profile::DesktopProfile::new(
181+
format!("wss://{host}"),
182+
uuid::Uuid::new_v4().simple().to_string(),
183+
)
184+
.unwrap();
185+
let prepared = profile.sign(&owner).unwrap();
186+
// Model bytes committed during yesterday's offline first launch. Neither
187+
// the first submission nor its duplicate is re-dated or re-signed below.
188+
let now = Timestamp::now().as_secs();
189+
let aged = EventBuilder::new(prepared.kind, &prepared.content)
190+
.tags(prepared.tags.iter().cloned())
191+
.custom_created_at(Timestamp::from(now - 86_400))
192+
.sign_with_keys(&owner)
193+
.unwrap();
194+
let raw = json!(aged);
195+
for _ in 0..2 {
196+
let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await;
197+
assert_eq!(status, StatusCode::OK, "{result}");
198+
assert_eq!(result["accepted"], true, "{result}");
199+
let (status, rows) = post(
200+
&state,
201+
&host,
202+
"/query",
203+
&owner,
204+
json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "ids":[aged.id.to_hex()]}]),
205+
true,
206+
)
207+
.await;
208+
assert_eq!(status, StatusCode::OK, "{rows}");
209+
assert_eq!(rows.as_array().unwrap().len(), 1);
210+
for field in [
211+
"id",
212+
"pubkey",
213+
"kind",
214+
"created_at",
215+
"tags",
216+
"content",
217+
"sig",
218+
] {
219+
assert_eq!(rows[0][field], raw[field], "stored {field} changed");
220+
}
221+
let stored: nostr::Event = serde_json::from_value(rows[0].clone()).unwrap();
222+
assert_eq!(
223+
buzz_core::desktop_profile::DesktopProfile::read(
224+
&stored,
225+
&owner,
226+
&format!("wss://{host}")
227+
)
228+
.unwrap(),
229+
profile
230+
);
231+
}
232+
// The age exception grants no signer authority and bypasses no envelope or
233+
// signature checks. These calls use the real HTTP -> shared ingest path.
234+
let (status, result) = post(&state, &host, "/events", &outsider, raw.clone(), true).await;
235+
assert_eq!(status, StatusCode::FORBIDDEN, "{result}");
236+
let mut corrupt = raw.clone();
237+
corrupt["content"] = json!(format!("{}x", aged.content));
238+
let (status, result) = post(&state, &host, "/events", &owner, corrupt, true).await;
239+
assert_eq!(status, StatusCode::BAD_REQUEST, "{result}");
240+
let invalid = EventBuilder::new(aged.kind, &aged.content)
241+
.tag(Tag::identifier("invalid-coordinate"))
242+
.custom_created_at(aged.created_at)
243+
.sign_with_keys(&owner)
244+
.unwrap();
245+
let future = EventBuilder::new(aged.kind, &aged.content)
246+
.tags(aged.tags.iter().cloned())
247+
.custom_created_at(Timestamp::from(now + 86_400))
248+
.sign_with_keys(&owner)
249+
.unwrap();
250+
let ordinary = EventBuilder::text_note("old ordinary event")
251+
.custom_created_at(aged.created_at)
252+
.sign_with_keys(&owner)
253+
.unwrap();
254+
for rejected in [invalid, future, ordinary] {
255+
let (status, result) = post(&state, &host, "/events", &owner, json!(rejected), true).await;
256+
assert_eq!(status, StatusCode::BAD_REQUEST, "{result}");
257+
}
258+
}

crates/buzz-relay/src/handlers/ingest.rs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2169,6 +2169,17 @@ pub async fn ingest_event(
21692169
result
21702170
}
21712171

2172+
// Profiles are durable display records, not freshness signals. A Desktop may
2173+
// first publish its immutable signed record long after an offline startup.
2174+
// Only their past-age bound is waived; future drift and all other admission
2175+
// checks still apply. Observation/presence kinds must retain their own window.
2176+
fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool {
2177+
const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900;
2178+
event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS)
2179+
&& (kind == KIND_DESKTOP_PROFILE
2180+
|| now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS)
2181+
}
2182+
21722183
async fn ingest_event_inner(
21732184
state: &Arc<AppState>,
21742185
tracer: &Arc<dyn buzz_conformance::Tracer>,
@@ -2233,10 +2244,8 @@ async fn ingest_event_inner(
22332244
}
22342245
let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone());
22352246

2236-
const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes
22372247
let now = chrono::Utc::now().timestamp();
2238-
let event_ts = event.created_at.as_secs() as i64;
2239-
if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS {
2248+
if !timestamp_within_ingest_window(kind_u32, event.created_at.as_secs(), now as u64) {
22402249
return Err(IngestError::Rejected(
22412250
"invalid: event timestamp too far from server time".into(),
22422251
));
@@ -3317,6 +3326,40 @@ mod postgres_tests {
33173326
));
33183327
}
33193328

3329+
#[test]
3330+
fn immutable_profile_age_exception_is_past_only_and_kind_specific() {
3331+
let now = 1_800_000_000;
3332+
// Include the next observation kind explicitly: freshness is not profile age.
3333+
for kind in [
3334+
KIND_DESKTOP_PROFILE,
3335+
30181,
3336+
KIND_PROFILE,
3337+
KIND_EVENT_REMINDER,
3338+
1,
3339+
] {
3340+
for (timestamp, ordinary, profile) in [
3341+
(0, false, true),
3342+
(now - 86_400, false, true),
3343+
(now - 901, false, true),
3344+
(now - 900, true, true),
3345+
(now, true, true),
3346+
(now + 900, true, true),
3347+
(now + 901, false, false),
3348+
(u64::MAX, false, false),
3349+
] {
3350+
assert_eq!(
3351+
timestamp_within_ingest_window(kind, timestamp, now),
3352+
if kind == KIND_DESKTOP_PROFILE {
3353+
profile
3354+
} else {
3355+
ordinary
3356+
},
3357+
"kind={kind} timestamp={timestamp}"
3358+
);
3359+
}
3360+
}
3361+
}
3362+
33203363
#[test]
33213364
fn huddle_backing_channel_lookup_outage_is_internal() {
33223365
let error = sqlx::Error::Io(std::io::Error::other("database unavailable"));

0 commit comments

Comments
 (0)