From ff5b04ff10f6859595bd95e6f6eebe412e4a403e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 11:26:03 -0400 Subject: [PATCH] fix(relay): admit delayed immutable Desktop profiles Signed-off-by: Logan Johnson --- .../src/api/desktop_profile_postgres_tests.rs | 91 +++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 49 +++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs index 38c8d8af3b..922c4bb022 100644 --- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -165,3 +165,94 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() { } } } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn aged_desktop_profile_retries_through_production_ingest_without_resigning() { + let mut state = bridge_handler_test_state() + .await + .expect("test infrastructure"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true; + let host = format!("desktop-retry-{}.example", uuid::Uuid::new_v4().simple()); + state.db.ensure_configured_community(&host).await.unwrap(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let profile = buzz_core::desktop_profile::DesktopProfile::new( + format!("wss://{host}"), + uuid::Uuid::new_v4().simple().to_string(), + ) + .unwrap(); + let prepared = profile.sign(&owner).unwrap(); + // Model bytes committed during yesterday's offline first launch. Neither + // the first submission nor its duplicate is re-dated or re-signed below. + let now = Timestamp::now().as_secs(); + let aged = EventBuilder::new(prepared.kind, &prepared.content) + .tags(prepared.tags.iter().cloned()) + .custom_created_at(Timestamp::from(now - 86_400)) + .sign_with_keys(&owner) + .unwrap(); + let raw = json!(aged); + for _ in 0..2 { + let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true, "{result}"); + let (status, rows) = post( + &state, + &host, + "/query", + &owner, + json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "ids":[aged.id.to_hex()]}]), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows.as_array().unwrap().len(), 1); + for field in [ + "id", + "pubkey", + "kind", + "created_at", + "tags", + "content", + "sig", + ] { + assert_eq!(rows[0][field], raw[field], "stored {field} changed"); + } + let stored: nostr::Event = serde_json::from_value(rows[0].clone()).unwrap(); + assert_eq!( + buzz_core::desktop_profile::DesktopProfile::read( + &stored, + &owner, + &format!("wss://{host}") + ) + .unwrap(), + profile + ); + } + // The age exception grants no signer authority and bypasses no envelope or + // signature checks. These calls use the real HTTP -> shared ingest path. + let (status, result) = post(&state, &host, "/events", &outsider, raw.clone(), true).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); + let mut corrupt = raw.clone(); + corrupt["content"] = json!(format!("{}x", aged.content)); + let (status, result) = post(&state, &host, "/events", &owner, corrupt, true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + let invalid = EventBuilder::new(aged.kind, &aged.content) + .tag(Tag::identifier("invalid-coordinate")) + .custom_created_at(aged.created_at) + .sign_with_keys(&owner) + .unwrap(); + let future = EventBuilder::new(aged.kind, &aged.content) + .tags(aged.tags.iter().cloned()) + .custom_created_at(Timestamp::from(now + 86_400)) + .sign_with_keys(&owner) + .unwrap(); + let ordinary = EventBuilder::text_note("old ordinary event") + .custom_created_at(aged.created_at) + .sign_with_keys(&owner) + .unwrap(); + for rejected in [invalid, future, ordinary] { + let (status, result) = post(&state, &host, "/events", &owner, json!(rejected), true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 613544df9d..13c781613f 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2169,6 +2169,17 @@ pub async fn ingest_event( result } +// Profiles are durable display records, not freshness signals. A Desktop may +// first publish its immutable signed record long after an offline startup. +// Only their past-age bound is waived; future drift and all other admission +// checks still apply. Observation/presence kinds must retain their own window. +fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool { + const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900; + event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS) + && (kind == KIND_DESKTOP_PROFILE + || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) +} + async fn ingest_event_inner( state: &Arc, tracer: &Arc, @@ -2233,10 +2244,8 @@ async fn ingest_event_inner( } let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone()); - const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes let now = chrono::Utc::now().timestamp(); - let event_ts = event.created_at.as_secs() as i64; - if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { + if !timestamp_within_ingest_window(kind_u32, event.created_at.as_secs(), now as u64) { return Err(IngestError::Rejected( "invalid: event timestamp too far from server time".into(), )); @@ -3317,6 +3326,40 @@ mod postgres_tests { )); } + #[test] + fn immutable_profile_age_exception_is_past_only_and_kind_specific() { + let now = 1_800_000_000; + // Include the next observation kind explicitly: freshness is not profile age. + for kind in [ + KIND_DESKTOP_PROFILE, + 30181, + KIND_PROFILE, + KIND_EVENT_REMINDER, + 1, + ] { + for (timestamp, ordinary, profile) in [ + (0, false, true), + (now - 86_400, false, true), + (now - 901, false, true), + (now - 900, true, true), + (now, true, true), + (now + 900, true, true), + (now + 901, false, false), + (u64::MAX, false, false), + ] { + assert_eq!( + timestamp_within_ingest_window(kind, timestamp, now), + if kind == KIND_DESKTOP_PROFILE { + profile + } else { + ordinary + }, + "kind={kind} timestamp={timestamp}" + ); + } + } + } + #[test] fn huddle_backing_channel_lookup_outage_is_internal() { let error = sqlx::Error::Io(std::io::Error::other("database unavailable"));