feat(push): deliver accepted relay events as wakes (#1866)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-14 16:13:04 -04:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent 8b2dd6d51a
commit bffbc5f22c
14 changed files with 1335 additions and 28 deletions
+110
View File
@@ -951,6 +951,116 @@ impl Db {
event::get_events_by_ids(&self.pool, community_id, ids).await
}
/// Exclusively claim the next due event-to-push matcher job.
pub async fn claim_due_push_match(
&self,
lease_until: DateTime<Utc>,
) -> Result<Option<push::ClaimedMatch>> {
push::claim_due_match(&self.pool, lease_until).await
}
/// Load active endpoint-enabled leases eligible for push matching.
pub async fn active_push_match_leases(
&self,
community: CommunityId,
) -> Result<Vec<push::MatchLease>> {
push::active_match_leases(&self.pool, community).await
}
/// Complete a matcher job if its claim fence is still held.
pub async fn complete_push_match(&self, job: &push::ClaimedMatch) -> Result<bool> {
push::complete_match(&self.pool, job).await
}
/// Release a matcher claim for retry at the supplied time.
pub async fn retry_push_match(
&self,
job: &push::ClaimedMatch,
next: DateTime<Utc>,
) -> Result<bool> {
push::retry_match(&self.pool, job, next).await
}
/// Idempotently enqueue a wake for a matched lease and event.
pub async fn enqueue_push_wake(
&self,
community: CommunityId,
author: &[u8],
installation_id: &str,
wake: push::NewWake<'_>,
) -> Result<push::EnqueueWakeOutcome> {
push::enqueue_wake(&self.pool, community, author, installation_id, wake).await
}
/// Exclusively claim due wake jobs for one community.
pub async fn claim_due_push_wakes(
&self,
community: CommunityId,
limit: i64,
lease_until: DateTime<Utc>,
) -> Result<Vec<push::ClaimedWake>> {
push::claim_due_wakes(&self.pool, community, limit, lease_until).await
}
/// Revalidate a wake's claim, source event, and current lease before send.
pub async fn revalidate_push_wake(
&self,
community: CommunityId,
id: Uuid,
claim_id: Uuid,
) -> Result<push::RevalidateWakeOutcome> {
push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await
}
/// Mark a fenced wake claim delivered.
pub async fn complete_push_wake(
&self,
community: CommunityId,
id: Uuid,
claim_id: Uuid,
) -> Result<bool> {
push::complete_wake(&self.pool, community, id, claim_id).await
}
/// Release a fenced wake claim for retry at the supplied time.
pub async fn retry_push_wake(
&self,
community: CommunityId,
id: Uuid,
claim_id: Uuid,
next: DateTime<Utc>,
) -> Result<bool> {
push::retry_wake(&self.pool, community, id, claim_id, next).await
}
/// Mark a fenced wake claim terminally failed.
pub async fn fail_push_wake(
&self,
community: CommunityId,
id: Uuid,
claim_id: Uuid,
) -> Result<bool> {
push::fail_wake(&self.pool, community, id, claim_id).await
}
/// Disable an endpoint only if the specified lease generation is current.
pub async fn disable_push_endpoint(
&self,
community: CommunityId,
author: &[u8],
installation_id: &str,
generation: i64,
) -> Result<bool> {
push::disable_endpoint_generation(
&self.pool,
community,
author,
installation_id,
generation,
)
.await
}
/// Atomically persist a validated kind:30350 event and its effective lease.
#[allow(clippy::too_many_arguments)]
pub async fn accept_push_lease_event(
+13 -2
View File
@@ -549,7 +549,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 17);
assert_eq!(migrations.len(), 18);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -765,6 +765,8 @@ mod tests {
.as_str()
.contains("_operator_global_tables"));
// Community archival and product feedback landed concurrently. Keep
// both additive migrations in a single, unambiguous sequence.
assert_eq!(migrations[15].version, 16);
assert!(migrations[15]
.sql
@@ -787,6 +789,15 @@ mod tests {
.as_str()
.contains("('product_feedback', 'deployment product inbox"));
assert!(!migrations[0].sql.as_str().contains("product_feedback"));
// Matching is driven from a parent-table trigger so all partition and
// internal insertion paths share the same crash-safe allowlist seam.
assert_eq!(migrations[17].version, 18);
let matcher = migrations[17].sql.as_str();
assert!(matcher.contains("CREATE TABLE push_match_queue"));
assert!(matcher.contains("AFTER INSERT ON events"));
assert!(matcher.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
assert!(!migrations[0].sql.as_str().contains("push_match_queue"));
}
#[test]
@@ -1029,7 +1040,7 @@ mod tests {
run_migrations(&pool)
.await
.expect("retry succeeds after operator repair");
assert_eq!(applied_versions(&pool).await.last().copied(), Some(17));
assert_eq!(applied_versions(&pool).await.last().copied(), Some(18));
}
#[tokio::test]
+449 -15
View File
@@ -12,6 +12,9 @@ use uuid::Uuid;
use crate::error::Result;
/// Maximum claims for a malformed matcher job before it is discarded.
pub const MAX_MATCH_ATTEMPTS: i32 = 8;
/// Common signed-event ordering fields for a lease replacement.
#[derive(Debug, Clone, Copy)]
pub struct LeaseVersion<'a> {
@@ -78,10 +81,16 @@ pub struct NewWake<'a> {
/// One exclusively claimed wake, already revalidated against its current lease.
#[derive(Debug, Clone, PartialEq)]
pub struct ClaimedWake {
/// Server-resolved tenant that owns this wake.
pub community: CommunityId,
/// Durable job id; this is also the stable gateway/APNs request id.
pub id: Uuid,
/// Claim fencing token required by every completion operation.
pub claim_id: Uuid,
/// Accepted event that caused the wake.
pub event_id: Vec<u8>,
/// Event channel used for send-time authorization revalidation.
pub channel_id: Option<Uuid>,
/// Lease author whose read authorization must be rechecked by the relay.
pub author: Vec<u8>,
/// Installation address within the community.
@@ -102,11 +111,39 @@ pub struct ClaimedWake {
#[derive(Debug, Clone, PartialEq)]
pub enum RevalidateWakeOutcome {
/// The claim and current lease still authorize delivery.
Deliver(ClaimedWake),
Deliver(Box<ClaimedWake>),
/// The claim was lost or the lease rotated, revoked, expired, or disabled.
Suppressed,
}
/// One durably accepted event claimed for push matching.
#[derive(Debug, Clone)]
pub struct ClaimedMatch {
/// Tenant that owns both the event and matcher job.
pub community: CommunityId,
/// Non-deleted source event loaded after the claim commits.
pub event: buzz_core::StoredEvent,
/// Fencing token required to complete or retry this claim.
pub claim_id: Uuid,
/// Attempt number, starting at one for the first claim.
pub attempt: i32,
}
/// Current active lease candidate for matcher evaluation.
#[derive(Debug, Clone)]
pub struct MatchLease {
/// Lease owner's raw public key.
pub author: Vec<u8>,
/// Installation address within the tenant.
pub installation_id: String,
/// Monotonic generation captured into any resulting wake.
pub generation: i64,
/// Validated restricted subscription array.
pub subscriptions: Value,
/// Lease expiry as a Unix timestamp.
pub expires_at: i64,
}
/// Result of atomically accepting a signed push lease and its effective state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcceptLeaseOutcome {
@@ -538,6 +575,139 @@ pub async fn enqueue_wake(
Ok(outcome)
}
/// Exclusively claim the next due matcher job and load its non-deleted event.
pub async fn claim_due_match(
pool: &PgPool,
lease_until: DateTime<Utc>,
) -> Result<Option<ClaimedMatch>> {
claim_due_match_with_loader(pool, lease_until, |pool, community, event_id| async move {
Ok(
crate::event::get_events_by_ids(&pool, community, &[&event_id])
.await?
.into_iter()
.next(),
)
})
.await
}
async fn claim_due_match_with_loader<F, Fut>(
pool: &PgPool,
lease_until: DateTime<Utc>,
load: F,
) -> Result<Option<ClaimedMatch>>
where
F: FnOnce(PgPool, CommunityId, Vec<u8>) -> Fut,
Fut: std::future::Future<Output = Result<Option<buzz_core::StoredEvent>>>,
{
let claim_id = Uuid::new_v4();
let mut tx = pool.begin().await?;
// Reap poison jobs before claiming so a worker crash on the final attempt
// cannot leave an unclaimable row pinning outbox retention forever.
sqlx::query(
"DELETE FROM push_match_queue WHERE attempts >= $1 \
AND (state='pending' OR (state='matching' AND lease_until < now()))",
)
.bind(MAX_MATCH_ATTEMPTS)
.execute(&mut *tx)
.await?;
let row = sqlx::query(
r#"
WITH candidate AS (
SELECT community_id, event_id
FROM push_match_queue
WHERE attempts < $3
AND next_attempt_at <= now()
AND (state = 'pending' OR (state = 'matching' AND lease_until < now()))
ORDER BY next_attempt_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE push_match_queue q
SET state='matching', claim_id=$1, lease_until=$2, attempts=attempts+1
FROM candidate c
WHERE q.community_id=c.community_id AND q.event_id=c.event_id
RETURNING q.community_id, q.event_id, q.attempts
"#,
)
.bind(claim_id)
.bind(lease_until)
.bind(MAX_MATCH_ATTEMPTS)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let community = CommunityId::from_uuid(row.try_get("community_id")?);
let event_id: Vec<u8> = row.try_get("event_id")?;
let attempt: i32 = row.try_get("attempts")?;
tx.commit().await?;
let event = load(pool.clone(), community, event_id.clone()).await?;
let Some(event) = event else {
// Source absence and soft deletion are deliberate privacy-preserving
// terminal outcomes. Query errors above propagate instead, leaving the
// fenced job recoverable after its claim lease expires.
sqlx::query(
"DELETE FROM push_match_queue \
WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'",
)
.bind(community.as_uuid())
.bind(&event_id)
.bind(claim_id)
.execute(pool)
.await?;
return Ok(None);
};
Ok(Some(ClaimedMatch {
community,
event,
claim_id,
attempt,
}))
}
/// Load active endpoint-enabled leases for one tenant.
pub async fn active_match_leases(pool: &PgPool, community: CommunityId) -> Result<Vec<MatchLease>> {
let rows = sqlx::query(
"SELECT author, installation_id, generation, subscriptions, expires_at \
FROM push_leases WHERE community_id=$1 AND active AND endpoint_enabled \
AND expires_at > EXTRACT(EPOCH FROM now())::bigint",
)
.bind(community.as_uuid())
.fetch_all(pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(MatchLease {
author: row.try_get("author")?,
installation_id: row.try_get("installation_id")?,
generation: row.try_get("generation")?,
subscriptions: row.try_get("subscriptions")?,
expires_at: row.try_get("expires_at")?,
})
})
.collect()
}
/// Delete a matcher job only while its claim fence is held.
pub async fn complete_match(pool: &PgPool, match_job: &ClaimedMatch) -> Result<bool> {
Ok(sqlx::query("DELETE FROM push_match_queue WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'")
.bind(match_job.community.as_uuid()).bind(match_job.event.event.id.as_bytes().as_slice())
.bind(match_job.claim_id).execute(pool).await?.rows_affected() == 1)
}
/// Release a fenced matcher claim for retry at the supplied time.
pub async fn retry_match(
pool: &PgPool,
match_job: &ClaimedMatch,
next: DateTime<Utc>,
) -> Result<bool> {
Ok(sqlx::query("UPDATE push_match_queue SET state='pending', claim_id=NULL, lease_until=NULL, next_attempt_at=$4 WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'")
.bind(match_job.community.as_uuid()).bind(match_job.event.event.id.as_bytes().as_slice())
.bind(match_job.claim_id).bind(next).execute(pool).await?.rows_affected() == 1)
}
/// Claim due jobs for one community, recovering expired worker leases.
///
/// Claiming performs an early lease check, but callers MUST invoke
@@ -552,7 +722,7 @@ pub async fn claim_due_wakes(
let rows = sqlx::query(
r#"
WITH candidates AS (
SELECT o.id
SELECT o.id, e.channel_id
FROM push_wake_outbox o
JOIN push_leases l
ON l.community_id = o.community_id
@@ -560,7 +730,12 @@ pub async fn claim_due_wakes(
AND l.installation_id = o.installation_id
AND l.generation = o.lease_generation
AND l.endpoint_hash = o.endpoint_hash
LEFT JOIN events e
ON e.community_id = o.community_id
AND e.id = o.event_id
AND e.deleted_at IS NULL
WHERE o.community_id = $1
AND e.id IS NOT NULL
AND o.expires_at > EXTRACT(EPOCH FROM now())::bigint
AND o.next_attempt_at <= now()
AND (o.state = 'pending' OR (o.state = 'sending' AND o.lease_until < now()))
@@ -581,9 +756,9 @@ pub async fn claim_due_wakes(
AND l.installation_id = o.installation_id
AND l.generation = o.lease_generation
AND l.endpoint_hash = o.endpoint_hash
RETURNING o.id, o.claim_id, o.author, o.installation_id,
o.lease_generation, l.endpoint_grant, o.class,
o.expires_at, o.attempts
RETURNING o.community_id, o.id, o.claim_id, o.event_id, c.channel_id,
o.author, o.installation_id, o.lease_generation,
l.endpoint_grant, o.class, o.expires_at, o.attempts
"#,
)
.bind(community.as_uuid())
@@ -609,9 +784,9 @@ pub async fn revalidate_wake_for_send(
) -> Result<RevalidateWakeOutcome> {
let row = sqlx::query(
r#"
SELECT o.id, o.claim_id, o.author, o.installation_id,
o.lease_generation, l.endpoint_grant, o.class,
o.expires_at, o.attempts
SELECT o.community_id, o.id, o.claim_id, o.event_id, e.channel_id,
o.author, o.installation_id, o.lease_generation,
l.endpoint_grant, o.class, o.expires_at, o.attempts
FROM push_wake_outbox o
JOIN push_leases l
ON l.community_id = o.community_id
@@ -619,6 +794,10 @@ pub async fn revalidate_wake_for_send(
AND l.installation_id = o.installation_id
AND l.generation = o.lease_generation
AND l.endpoint_hash = o.endpoint_hash
JOIN events e
ON e.community_id = o.community_id
AND e.id = o.event_id
AND e.deleted_at IS NULL
WHERE o.community_id = $1
AND o.id = $2
AND o.claim_id = $3
@@ -639,7 +818,7 @@ pub async fn revalidate_wake_for_send(
row.map(row_to_claimed_wake)
.transpose()?
.map_or(Ok(RevalidateWakeOutcome::Suppressed), |wake| {
Ok(RevalidateWakeOutcome::Deliver(wake))
Ok(RevalidateWakeOutcome::Deliver(Box::new(wake)))
})
}
@@ -731,16 +910,24 @@ pub async fn disable_endpoint_generation(
}
/// Delete terminal/expired outbox rows older than a retention cutoff.
///
/// NIP-RS hard purge only targets kind 30078, which is not push-eligible and
/// therefore cannot have a matcher row; any other absent source is handled by
/// the matcher's fenced load-miss deletion.
pub async fn prune_wake_outbox(
pool: &PgPool,
community: CommunityId,
before: DateTime<Utc>,
) -> Result<u64> {
let result = sqlx::query(
"DELETE FROM push_wake_outbox \
WHERE community_id = $1 AND created_at < $2 \
AND (state IN ('delivered', 'failed') \
OR expires_at <= EXTRACT(EPOCH FROM now())::bigint)",
"DELETE FROM push_wake_outbox o \
WHERE o.community_id = $1 AND o.created_at < $2 \
AND (o.state IN ('delivered', 'failed') \
OR o.expires_at <= EXTRACT(EPOCH FROM now())::bigint) \
AND NOT EXISTS ( \
SELECT 1 FROM push_match_queue q \
WHERE q.community_id = o.community_id AND q.event_id = o.event_id \
)",
)
.bind(community.as_uuid())
.bind(before)
@@ -751,8 +938,11 @@ pub async fn prune_wake_outbox(
fn row_to_claimed_wake(row: sqlx::postgres::PgRow) -> Result<ClaimedWake> {
Ok(ClaimedWake {
community: CommunityId::from_uuid(row.try_get("community_id")?),
id: row.try_get("id")?,
claim_id: row.try_get("claim_id")?,
event_id: row.try_get("event_id")?,
channel_id: row.try_get("channel_id")?,
author: row.try_get("author")?,
installation_id: row.try_get("installation_id")?,
lease_generation: row.try_get("lease_generation")?,
@@ -1090,9 +1280,20 @@ mod tests {
pool: &PgPool,
community: CommunityId,
author: &[u8],
event: &[u8],
event_id: &[u8; 32],
generation: i64,
) -> Uuid {
sqlx::query(
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig) \
VALUES ($1, $2, $3, to_timestamp(1), 9, '[]', '', $4)",
)
.bind(community.as_uuid())
.bind(event_id)
.bind([42_u8; 32])
.bind([43_u8; 64])
.execute(pool)
.await
.expect("insert wake source event");
match enqueue_wake(
pool,
community,
@@ -1100,7 +1301,7 @@ mod tests {
"install",
NewWake {
lease_generation: generation,
event_id: event,
event_id,
class: "default",
expires_at: i64::MAX / 2,
},
@@ -1261,4 +1462,237 @@ mod tests {
EnqueueWakeOutcome::Enqueued(_)
));
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn matcher_trigger_is_allowlisted_and_deleted_events_are_discarded() {
let pool = setup_pool().await;
let community = make_community(&pool).await;
let keys = nostr::Keys::generate();
let push_event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "push")
.sign_with_keys(&keys)
.expect("sign push event");
let read_state = nostr::EventBuilder::new(nostr::Kind::Custom(30_078), "read")
.sign_with_keys(&keys)
.expect("sign read state");
crate::event::insert_event(&pool, community, &push_event, None)
.await
.expect("insert push event");
crate::event::insert_event(&pool, community, &read_state, None)
.await
.expect("insert non-push event");
let queued: Vec<i32> = sqlx::query_scalar(
"SELECT e.kind FROM push_match_queue q JOIN events e \
ON e.community_id=q.community_id AND e.id=q.event_id \
WHERE q.community_id=$1",
)
.bind(community.as_uuid())
.fetch_all(&pool)
.await
.expect("read matcher queue");
assert_eq!(queued, vec![9]);
sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2")
.bind(community.as_uuid())
.bind(push_event.id.as_bytes().as_slice())
.execute(&pool)
.await
.expect("soft delete before matching");
assert!(
claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1))
.await
.expect("claim deleted event")
.is_none()
);
let remaining: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_match_queue WHERE community_id=$1")
.bind(community.as_uuid())
.fetch_one(&pool)
.await
.expect("count discarded job");
assert_eq!(remaining, 0, "deleted content must never produce a wake");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn matcher_load_error_preserves_claimed_job_for_recovery() {
let pool = setup_pool().await;
let community = make_community(&pool).await;
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "retry me")
.sign_with_keys(&nostr::Keys::generate())
.expect("sign event");
crate::event::insert_event(&pool, community, &event, None)
.await
.expect("insert event");
let error = claim_due_match_with_loader(
&pool,
Utc::now() - chrono::Duration::seconds(1),
|_pool, _community, _event_id| async {
Err(crate::DbError::InvalidData("injected load failure".into()))
},
)
.await
.expect_err("load error must propagate");
assert!(error.to_string().contains("injected load failure"));
let row: (String, i32) = sqlx::query_as(
"SELECT state, attempts FROM push_match_queue WHERE community_id=$1 AND event_id=$2",
)
.bind(community.as_uuid())
.bind(event.id.as_bytes().as_slice())
.fetch_one(&pool)
.await
.expect("load failure must preserve matcher row");
assert_eq!(row, ("matching".to_string(), 1));
assert!(
claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1))
.await
.expect("expired claim remains recoverable")
.is_some()
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn matcher_claim_is_exclusive_across_workers() {
let pool = setup_pool().await;
let community = make_community(&pool).await;
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "one job")
.sign_with_keys(&nostr::Keys::generate())
.expect("sign event");
crate::event::insert_event(&pool, community, &event, None)
.await
.expect("insert event");
let barrier = Arc::new(Barrier::new(8));
let mut tasks = Vec::new();
for _ in 0..8 {
let pool = pool.clone();
let barrier = Arc::clone(&barrier);
tasks.push(tokio::spawn(async move {
barrier.wait().await;
claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1))
.await
.expect("claim matcher job")
}));
}
let mut claimed = 0;
for task in tasks {
claimed += usize::from(task.await.expect("join").is_some());
}
assert_eq!(claimed, 1);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn delivered_wake_is_retained_while_rematch_is_queued() {
let pool = setup_pool().await;
let community = make_community(&pool).await;
let author = [22; 32];
let event_id = [23; 32];
activate(&pool, community, &author, "install", &[24; 32], 1).await;
let wake_id = enqueue_one(&pool, community, &author, &event_id, 1).await;
sqlx::query(
"UPDATE push_wake_outbox SET state='delivered', created_at=now()-interval '2 days' \
WHERE community_id=$1 AND id=$2",
)
.bind(community.as_uuid())
.bind(wake_id)
.execute(&pool)
.await
.expect("mark old wake delivered");
let cutoff = Utc::now() - chrono::Duration::days(1);
assert_eq!(
prune_wake_outbox(&pool, community, cutoff).await.unwrap(),
0
);
sqlx::query("DELETE FROM push_match_queue WHERE community_id=$1 AND event_id=$2")
.bind(community.as_uuid())
.bind(event_id)
.execute(&pool)
.await
.expect("complete rematch");
assert_eq!(
prune_wake_outbox(&pool, community, cutoff).await.unwrap(),
1
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn exhausted_match_job_is_reaped_and_cannot_pin_retention() {
let pool = setup_pool().await;
let community = make_community(&pool).await;
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "poison")
.sign_with_keys(&nostr::Keys::generate())
.expect("sign event");
crate::event::insert_event(&pool, community, &event, None)
.await
.expect("insert event");
let author = [25; 32];
activate(&pool, community, &author, "install", &[26; 32], 1).await;
let wake_id = match enqueue_wake(
&pool,
community,
&author,
"install",
NewWake {
lease_generation: 1,
event_id: event.id.as_bytes(),
class: "default",
expires_at: i64::MAX / 2,
},
)
.await
.expect("enqueue wake")
{
EnqueueWakeOutcome::Enqueued(id) => id,
other => panic!("expected fresh wake, got {other:?}"),
};
sqlx::query(
"UPDATE push_wake_outbox SET state='delivered', created_at=now()-interval '2 days' \
WHERE community_id=$1 AND id=$2",
)
.bind(community.as_uuid())
.bind(wake_id)
.execute(&pool)
.await
.expect("mark old wake delivered");
let cutoff = Utc::now() - chrono::Duration::days(1);
assert_eq!(
prune_wake_outbox(&pool, community, cutoff).await.unwrap(),
0
);
sqlx::query(
"UPDATE push_match_queue SET attempts=$3, state='matching', lease_until=now()-interval '1 second' \
WHERE community_id=$1 AND event_id=$2",
)
.bind(community.as_uuid())
.bind(event.id.as_bytes().as_slice())
.bind(MAX_MATCH_ATTEMPTS)
.execute(&pool)
.await
.expect("exhaust matcher job");
assert!(
claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1))
.await
.expect("reap exhausted matcher")
.is_none()
);
let remaining: i64 = sqlx::query_scalar(
"SELECT count(*) FROM push_match_queue WHERE community_id=$1 AND event_id=$2",
)
.bind(community.as_uuid())
.bind(event.id.as_bytes().as_slice())
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining, 0);
assert_eq!(
prune_wake_outbox(&pool, community, cutoff).await.unwrap(),
1,
"reaped poison job must release delivered-wake retention"
);
}
}
+1
View File
@@ -54,6 +54,7 @@ redis = { workspace = true }
# main(). Mirrors buzz-acp's rustls setup for wss://.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
sqlx = { workspace = true }
reqwest = { workspace = true }
base64 = "0.22"
buzz-sdk = { workspace = true }
buzz-workflow = { workspace = true, features = ["reqwest"] }
+34 -5
View File
@@ -216,6 +216,8 @@ fn parse_operator_api_origin(raw: &str) -> Result<String, ConfigError> {
Ok(raw.trim_end_matches('/').to_string())
}
const DEFAULT_PUSH_GATEWAY_DELIVERY_URL: &str = "https://push.buzz.xyz/v1/deliveries/apns";
fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
let url = url::Url::parse(raw.trim()).map_err(|e| {
ConfigError::InvalidValue(format!(
@@ -535,11 +537,13 @@ impl Config {
"BUZZ_PUSH_EXECUTOR_KEY_ID must contain 1..=64 bytes".to_string(),
));
}
let push_gateway_delivery_url = std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL")
.ok()
.filter(|raw| !raw.trim().is_empty())
.map(|raw| parse_push_gateway_delivery_url(&raw))
.transpose()?;
let push_gateway_delivery_url = match std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL") {
Ok(raw) if raw.trim().is_empty() => None,
Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?),
Err(_) => Some(parse_push_gateway_delivery_url(
DEFAULT_PUSH_GATEWAY_DELIVERY_URL,
)?),
};
let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") {
Ok(raw) => raw
.parse::<u64>()
@@ -739,6 +743,31 @@ mod tests {
));
}
#[test]
fn push_gateway_defaults_to_buzz_and_can_be_disabled() {
let _guard = ENV_MUTEX.lock().unwrap();
let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
let config = Config::from_env().expect("default config");
assert_eq!(
config
.push_gateway_delivery_url
.as_ref()
.map(url::Url::as_str),
Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL)
);
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", "");
let config = Config::from_env().expect("disabled push config");
assert!(config.push_gateway_delivery_url.is_none());
if let Some(value) = previous {
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", value);
} else {
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
}
}
#[test]
fn push_gateway_url_is_exact_and_fail_closed() {
assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok());
+20 -2
View File
@@ -12,6 +12,9 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Number, Value};
use sha2::Digest as _;
pub(crate) const PUSH_KINDS: &[u64] = &[7, 9, 1059, 40007, 46010];
pub(crate) const URGENT_KINDS: &[u64] = &[];
/// NIP-PL addressable push-lease event kind.
pub const KIND_PUSH_LEASE: u32 = 30_350;
/// Largest integer represented exactly by interoperable JSON number implementations.
@@ -504,8 +507,8 @@ pub async fn accept(
},
],
supported_classes: &["silent", "default", "time_sensitive"],
push_kinds: &[7, 9, 1059, 40007, 46010],
urgent_kinds: &[],
push_kinds: PUSH_KINDS,
urgent_kinds: URGENT_KINDS,
max_subscriptions: 16,
max_kinds: 16,
max_authors: 20,
@@ -691,6 +694,21 @@ mod tests {
}
}
#[test]
fn migration_trigger_allowlist_matches_advertised_push_kinds() {
let kinds = PUSH_KINDS
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(", ");
let predicate = format!("NEW.kind IN ({kinds})");
let migration = include_str!("../../../../migrations/0018_push_match_queue.sql");
assert!(
migration.contains(&predicate),
"migration trigger must use PUSH_KINDS exactly: {predicate}"
);
}
#[test]
fn active_filter_requires_narrowing_and_self_p_tag() {
let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9]},"class":"default"}]}"##, 4096).unwrap();
+2
View File
@@ -29,6 +29,8 @@ pub mod metrics;
pub mod nip11;
/// NIP-01 client/relay message parsing.
pub mod protocol;
/// Durable NIP-PL matcher and delivery worker.
pub mod push_runtime;
/// Axum router construction.
pub mod router;
/// Shared application state.
+11
View File
@@ -599,6 +599,17 @@ async fn main() -> anyhow::Result<()> {
});
}
// NIP-PL matcher and worker are enabled as one unit. Lease acceptance is
// already disabled without the exact gateway URL, so discovery and runtime
// cannot advertise or accumulate work for an undeliverable configuration.
if state.config.push_gateway_delivery_url.is_some() {
tokio::spawn(buzz_relay::push_runtime::run_matcher(Arc::clone(&state)));
tokio::spawn(buzz_relay::push_runtime::run_delivery_worker(Arc::clone(
&state,
)));
info!("NIP-PL push matcher and delivery worker started");
}
// NIP-ER reminder scheduler — polls for due reminders and publishes them
// to Redis pub/sub for cross-pod fan-out. Each pod's existing
// subscribe_local consumer picks them up and applies the author-only gate.
+89 -2
View File
@@ -40,6 +40,9 @@ pub struct RelayInfo {
/// Draft/extension protocol identifiers supported by this relay.
#[serde(skip_serializing_if = "Option::is_none")]
pub supported_extensions: Option<Vec<String>>,
/// NIP-PL executor descriptor. Present only when push delivery is configured.
#[serde(skip_serializing_if = "Option::is_none")]
pub push: Option<serde_json::Value>,
/// URL of the relay software repository.
pub software: String,
/// Relay software version string.
@@ -155,6 +158,7 @@ impl RelayInfo {
contact: None,
supported_nips,
supported_extensions: Some(vec!["nip-er".to_string()]),
push: None,
software: "https://github.com/block/buzz".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
limitation: Some(relay_limitation(max_message_length)),
@@ -176,6 +180,52 @@ pub async fn relay_info_handler(
axum::response::Json(nip11_document(&state, raw_host).await)
}
fn push_descriptor(
push_configured: bool,
relay_url: &str,
executor_key_id: &str,
relay_keypair: &nostr::Keys,
tenant_host: Option<&str>,
) -> Option<serde_json::Value> {
let host = tenant_host?;
push_configured.then_some(())?;
let scheme = if relay_url.starts_with("wss://") {
"wss"
} else {
"ws"
};
Some(serde_json::json!({
"origin": format!("{scheme}://{host}"),
"keys": [{
"id": executor_key_id,
"pubkey": relay_keypair.public_key().to_hex(),
"current": true
}],
"app_profiles": [
{"id": "buzz-ios-production", "transport": "apns"},
{"id": "buzz-ios-sandbox", "transport": "apns"}
],
"push_kinds": crate::handlers::push_lease::PUSH_KINDS,
"urgent_kinds": crate::handlers::push_lease::URGENT_KINDS,
"h_grammar": "uuid-v4-lowercase",
"class_support": {"apns": ["silent", "default", "time_sensitive"]},
"limitation": {
"max_lease_ttl": 2592000,
"max_leases_per_pubkey": 16,
"max_subscriptions_per_lease": 16,
"max_kinds": 16,
"max_authors": 20,
"max_h": 50,
"max_tag_values": 20,
"max_ignore": 8,
"max_content_len": 65536,
"max_plaintext_len": 32768,
"max_endpoint_len": 4096,
"max_string_len": 512
}
}))
}
/// Builds the served NIP-11 document for a request arriving on `raw_host`.
///
/// Centralised so the content-negotiated root handler and the dedicated
@@ -185,13 +235,34 @@ pub async fn relay_info_handler(
pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo {
let (relay_self, advertise_nip43) = nip11_facts(state);
let icon = workspace_icon_for_host(state, raw_host).await;
RelayInfo::build(
let mut info = RelayInfo::build(
relay_self.as_deref(),
icon.as_deref(),
advertise_nip43,
state.config.max_frame_bytes,
state.config.pairing_relay_url.as_deref(),
)
);
let tenant_host = if state.config.push_gateway_delivery_url.is_some() {
crate::tenant::bind_community(&state.db, raw_host)
.await
.ok()
.map(|tenant| tenant.host().to_owned())
} else {
None
};
if let Some(push) = push_descriptor(
state.config.push_gateway_delivery_url.is_some(),
&state.config.relay_url,
&state.config.push_executor_key_id,
&state.relay_keypair,
tenant_host.as_deref(),
) {
info.supported_extensions
.get_or_insert_default()
.push("nip-pl".to_string());
info.push = Some(push);
}
info
}
/// Fetches the workspace icon for the community bound to `raw_host`, as the
@@ -267,6 +338,22 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
mod tests {
use super::*;
#[test]
fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() {
let keys = nostr::Keys::generate();
assert!(
push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none()
);
assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none());
let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example"))
.expect("configured push descriptor");
assert_eq!(descriptor["origin"], "ws://tenant.example");
assert_eq!(
descriptor["push_kinds"],
serde_json::json!(crate::handlers::push_lease::PUSH_KINDS)
);
}
#[test]
fn supported_nips_includes_nip23_and_nip33() {
// Tests the production SUPPORTED_NIPS constant directly — no Config::from_env()
+505
View File
@@ -0,0 +1,505 @@
//! Durable NIP-PL event matcher and gateway delivery worker.
use std::{sync::Arc, time::Duration};
use base64::Engine as _;
use buzz_core::filter::{filters_match, reader_authorized_for_event};
use chrono::{TimeDelta, Utc};
use nostr::{EventBuilder, Filter, Kind, Tag};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use tracing::{error, warn};
use crate::{handlers::push_lease::Subscription, state::AppState};
const CLAIM_SECS: i64 = 30;
const EVENT_USEFUL_SECS: i64 = 3600;
const MAX_ATTEMPTS: i32 = 8;
#[derive(Serialize)]
struct DeliveryRequest<'a> {
v: u8,
endpoint_grant: &'a str,
request_id: uuid::Uuid,
expires_at: i64,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case", tag = "status")]
enum DeliveryResponse {
Accepted,
InvalidEndpoint {
generation: i64,
invalid_at: Option<i64>,
},
Retry {
retry_after_seconds: Option<i64>,
},
}
/// Continuously claim accepted events and match them against active leases.
pub async fn run_matcher(state: Arc<AppState>) {
loop {
let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS);
match state.db.claim_due_push_match(until).await {
Ok(Some(job)) => {
if let Err(e) = process_match(&state, &job).await {
warn!(event_id=%job.event.event.id, attempt=job.attempt, "push match failed: {e}");
if job.attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS {
// A poison event/lease must not retry forever or pin
// delivered outbox retention through the rematch guard.
let _ = state.db.complete_push_match(&job).await;
} else {
let _ = state
.db
.retry_push_match(&job, Utc::now() + TimeDelta::seconds(2))
.await;
}
} else if let Err(e) = state.db.complete_push_match(&job).await {
warn!(event_id=%job.event.event.id, "push match completion failed: {e}");
}
}
Ok(None) => tokio::time::sleep(Duration::from_millis(250)).await,
Err(e) => {
error!("push matcher claim failed: {e}");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
async fn process_match(state: &AppState, job: &buzz_db::push::ClaimedMatch) -> anyhow::Result<()> {
let leases = state.db.active_push_match_leases(job.community).await?;
for lease in leases {
let author_hex = hex::encode(&lease.author);
if !reader_authorized_for_event(&job.event.event, &author_hex) {
continue;
}
if let Some(channel) = job.event.channel_id {
if !state
.db
.is_member(job.community, channel, &lease.author)
.await?
{
continue;
}
}
let subscriptions: Vec<Subscription> = serde_json::from_value(lease.subscriptions.clone())?;
let mut class: Option<&str> = None;
for sub in &subscriptions {
let filter: Filter =
serde_json::from_value(serde_json::Value::Object(sub.filter.clone()))?;
if !push_filter_authorized_for_event(&filter, &job.event.event, &author_hex)
|| !filters_match(std::slice::from_ref(&filter), &job.event)
{
continue;
}
let ignored = sub.ignore.iter().any(|raw| {
serde_json::from_value::<Filter>(serde_json::Value::Object(raw.clone()))
.is_ok_and(|f| filters_match(&[f], &job.event))
});
let p_count = job
.event
.event
.tags
.iter()
.filter(|t| t.kind().to_string() == "p")
.count() as u64;
if ignored
|| sub
.suppress
.as_ref()
.is_some_and(|s| p_count > s.p_tags_max)
{
continue;
}
if class.is_none_or(|old| class_rank(&sub.class) > class_rank(old)) {
class = Some(&sub.class);
}
}
let Some(class) = class else { continue };
let event_deadline = job.event.event.created_at.as_secs() as i64 + EVENT_USEFUL_SECS;
let expires_at = lease.expires_at.min(event_deadline);
if expires_at <= Utc::now().timestamp() {
continue;
}
let _ = state
.db
.enqueue_push_wake(
job.community,
&lease.author,
&lease.installation_id,
buzz_db::push::NewWake {
lease_generation: lease.generation,
event_id: job.event.event.id.as_bytes(),
class,
expires_at,
},
)
.await?;
}
Ok(())
}
/// Match-time counterpart of REQ's filter-level `#p` authorization gate.
/// Kind 1059 is globally stored and leaks recipient activity through wake
/// timing, so a lease may only match gift wraps addressed to its own author.
fn push_filter_authorized_for_event(
filter: &Filter,
event: &nostr::Event,
lease_author_hex: &str,
) -> bool {
if buzz_core::kind::event_kind_u32(event) != buzz_core::kind::KIND_GIFT_WRAP {
return true;
}
let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P);
filter.generic_tags.get(&p).is_some_and(|values| {
!values.is_empty()
&& values.iter().all(|value| value == lease_author_hex)
&& event
.tags
.filter(nostr::TagKind::SingleLetter(p))
.any(|tag| tag.content() == Some(lease_author_hex))
})
}
/// Continuously claim due wakes and deliver them through the push gateway.
pub async fn run_delivery_worker(state: Arc<AppState>) {
let http = reqwest::Client::builder()
.timeout(state.config.push_gateway_timeout)
.build()
.expect("push HTTP client");
loop {
let mut found = false;
match state.db.usage_community_hosts().await {
Ok(communities) => {
for community in communities {
let community = buzz_core::CommunityId::from_uuid(community.id);
let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS);
match state.db.claim_due_push_wakes(community, 16, until).await {
Ok(wakes) => {
for wake in wakes {
found = true;
deliver_one(&state, &http, wake).await;
}
}
Err(e) => warn!(%community, "push wake claim failed: {e}"),
}
}
}
Err(e) => error!("push worker community scan failed: {e}"),
}
if !found {
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
async fn deliver_one(
state: &AppState,
http: &reqwest::Client,
claimed: buzz_db::push::ClaimedWake,
) {
let outcome = match state
.db
.revalidate_push_wake(claimed.community, claimed.id, claimed.claim_id)
.await
{
Ok(buzz_db::push::RevalidateWakeOutcome::Deliver(wake)) => wake,
Ok(buzz_db::push::RevalidateWakeOutcome::Suppressed) => {
let _ = state
.db
.fail_push_wake(claimed.community, claimed.id, claimed.claim_id)
.await;
return;
}
Err(e) => {
warn!(wake=%claimed.id, "push revalidation failed: {e}");
return;
}
};
if let Some(channel) = outcome.channel_id {
match state
.db
.is_member(outcome.community, channel, &outcome.author)
.await
{
Ok(true) => {}
Ok(false) => {
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
return;
}
Err(e) => {
warn!(wake=%outcome.id, "push membership revalidation failed: {e}");
let _ = state
.db
.retry_push_wake(
outcome.community,
outcome.id,
outcome.claim_id,
Utc::now() + TimeDelta::seconds(2),
)
.await;
return;
}
}
}
// Membership I/O above can race lease replacement. Re-run the generation
// fence as the final database operation before transport.
let outcome = match state
.db
.revalidate_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await
{
Ok(buzz_db::push::RevalidateWakeOutcome::Deliver(wake)) => wake,
Ok(buzz_db::push::RevalidateWakeOutcome::Suppressed) => {
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
return;
}
Err(e) => {
warn!(wake=%outcome.id, "final push revalidation failed: {e}");
return;
}
};
let Some(url) = state.config.push_gateway_delivery_url.as_ref() else {
return;
};
let body = delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at);
let auth = match nip98_header(&state.relay_keypair, url.as_str(), &body) {
Ok(auth) => auth,
Err(e) => {
warn!(wake=%outcome.id, "push auth failed: {e}");
return;
}
};
let response = send_gateway_request(http, url, body, auth).await;
match response {
Ok(r) if r.status().is_success() => match r.json::<DeliveryResponse>().await {
Ok(DeliveryResponse::Accepted) => {
let _ = state
.db
.complete_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
}
_ => {
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
}
},
Ok(r) if r.status() == reqwest::StatusCode::GONE => {
match r.json::<DeliveryResponse>().await {
Ok(DeliveryResponse::InvalidEndpoint {
generation,
invalid_at,
}) => {
if generation == outcome.lease_generation {
let _ = state
.db
.disable_push_endpoint(
outcome.community,
&outcome.author,
&outcome.installation_id,
generation,
)
.await;
}
warn!(wake=%outcome.id, ?invalid_at, "push endpoint permanently invalid");
}
_ => warn!(wake=%outcome.id, "invalid closed-protocol 410 response"),
}
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
}
Ok(r) if r.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
let delay = match r.json::<DeliveryResponse>().await {
Ok(DeliveryResponse::Retry {
retry_after_seconds,
}) => retry_after_seconds
.filter(|seconds| *seconds > 0)
.unwrap_or(2),
_ => 2,
};
retry_or_fail(state, &outcome, delay).await;
}
Ok(r) if r.status() == reqwest::StatusCode::TOO_MANY_REQUESTS => {
retry_or_fail(state, &outcome, 2).await
}
// A timed-out terminal attempt burns the stable request id. Its replay
// is indistinguishable from another invalid-grant 404, but sending a
// fresh id would double-deliver and defeat the gateway replay fence.
Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND && outcome.attempt > 1 => {
let _ = state
.db
.complete_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
}
Err(e) if e.is_timeout() || e.is_connect() => retry_or_fail(state, &outcome, 2).await,
_ => {
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
}
}
}
fn delivery_body(endpoint_grant: &str, request_id: uuid::Uuid, expires_at: i64) -> Vec<u8> {
serde_json::to_vec(&DeliveryRequest {
v: 1,
endpoint_grant,
request_id,
expires_at,
})
.expect("closed delivery body")
}
async fn send_gateway_request(
http: &reqwest::Client,
url: &url::Url,
body: Vec<u8>,
auth: String,
) -> reqwest::Result<reqwest::Response> {
http.post(url.clone())
.header("Authorization", auth)
.header("Content-Type", "application/json")
.body(body)
.send()
.await
}
async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, delay: i64) {
if wake.attempt >= MAX_ATTEMPTS {
let _ = state
.db
.fail_push_wake(wake.community, wake.id, wake.claim_id)
.await;
} else {
let secs = delay * (1_i64 << (wake.attempt - 1).clamp(0, 6));
let _ = state
.db
.retry_push_wake(
wake.community,
wake.id,
wake.claim_id,
Utc::now() + TimeDelta::seconds(secs),
)
.await;
}
}
fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result<String> {
let hash = hex::encode(Sha256::digest(body));
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags([
Tag::parse(["u", url])?,
Tag::parse(["method", "POST"])?,
Tag::parse(["payload", &hash])?,
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?,
])
.sign_with_keys(keys)?;
Ok(format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?)
))
}
fn class_rank(class: &str) -> u8 {
match class {
"silent" => 0,
"default" => 1,
"time_sensitive" => 2,
"urgent" => 3,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{extract::State, routing::post, Json, Router};
use serde_json::Value;
use std::{future::IntoFuture, sync::Arc};
use tokio::sync::Mutex;
#[test]
fn gift_wrap_match_requires_self_p_filter_and_recipient() {
let recipient = nostr::Keys::generate();
let other = nostr::Keys::generate();
let sender = nostr::Keys::generate();
let recipient_hex = recipient.public_key().to_hex();
let event = EventBuilder::new(Kind::GiftWrap, "ciphertext")
.tag(Tag::public_key(other.public_key()))
.sign_with_keys(&sender)
.unwrap();
let self_filter = Filter::new().pubkey(recipient.public_key());
assert!(!push_filter_authorized_for_event(
&self_filter,
&event,
&recipient_hex
));
let event = EventBuilder::new(Kind::GiftWrap, "ciphertext")
.tag(Tag::public_key(recipient.public_key()))
.sign_with_keys(&sender)
.unwrap();
assert!(push_filter_authorized_for_event(
&self_filter,
&event,
&recipient_hex
));
assert!(!push_filter_authorized_for_event(
&Filter::new().author(sender.public_key()),
&event,
&recipient_hex
));
}
async fn capture(
State(seen): State<Arc<Mutex<Vec<Value>>>>,
Json(body): Json<Value>,
) -> Json<Value> {
seen.lock().await.push(body);
Json(serde_json::json!({"status":"accepted"}))
}
#[tokio::test]
async fn gateway_retries_send_the_same_request_id_over_http() {
let seen = Arc::new(Mutex::new(Vec::new()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(
axum::serve(
listener,
Router::new()
.route("/deliver", post(capture))
.with_state(seen.clone()),
)
.into_future(),
);
let url: url::Url = format!("http://{address}/deliver").parse().unwrap();
let http = reqwest::Client::new();
let keys = nostr::Keys::generate();
let request_id = uuid::Uuid::new_v4();
for _ in 0..2 {
let body = delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60);
let auth = nip98_header(&keys, url.as_str(), &body).unwrap();
let response = send_gateway_request(&http, &url, body, auth).await.unwrap();
assert!(response.status().is_success());
}
server.abort();
let bodies = seen.lock().await;
assert_eq!(bodies.len(), 2);
assert_eq!(bodies[0]["request_id"], request_id.to_string());
assert_eq!(bodies[1]["request_id"], request_id.to_string());
}
}
+13 -2
View File
@@ -72,11 +72,22 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe
## Relay configuration
When the follow-up integration lands, each relay will point `BUZZ_PUSH_GATEWAY_DELIVERY_URL` at the same exact public delivery URL. Relays retain lease matching, authorization, coalescing, durable jobs/retries, and generation checks. They receive only opaque capabilities and never APNs tokens or provider credentials.
Relays default `BUZZ_PUSH_GATEWAY_DELIVERY_URL` to the exact public delivery URL
`https://push.buzz.xyz/v1/deliveries/apns`. Operators can override it with
another exact HTTPS `/v1/deliveries/apns` URL, or explicitly disable NIP-PL push
by setting the variable to an empty string. When enabled, the relay advertises
its host-scoped NIP-PL descriptor in NIP-11 and starts the matcher and delivery
worker. Relays retain lease matching, authorization, coalescing, durable
jobs/retries, and generation checks; they receive only opaque capabilities and
never APNs tokens or provider credentials.
## Relay integration status
This PR does **not** enable end-to-end push delivery from a relay. It lands the NIP-PL acceptance and durable lease/outbox primitives plus the independently deployable gateway, but intentionally does not start a relay matcher/worker. The missing client App Attest enrollment/delegation flow must first place a gateway-issued opaque capability—not a raw APNs token—into the encrypted lease. A follow-up must then add per-origin event matching with read-authorization rechecks, durable enqueue, send-time revalidation, and the NIP-98 delivery worker. Operators must leave `BUZZ_PUSH_GATEWAY_DELIVERY_URL` unset until that complete path lands; setting it currently gates lease acceptance only and does not start delivery.
The operational relay integration is complete: per-origin event matching with
read-authorization checks, durable enqueue, send-time revalidation, and NIP-98
delivery run whenever the gateway URL is enabled. End-to-end use still requires
the client App Attest enrollment/delegation flow to place a gateway-issued opaque
capability—not a raw APNs token—into the encrypted relay lease.
## Helm production inputs
+38
View File
@@ -0,0 +1,38 @@
-- Durable event-to-push matching follower. The trigger runs in the event insert
-- transaction, so every accepted persistent event has a crash-safe match job and
-- rejected/rolled-back events never do. Processing is idempotent through the
-- push_wake_outbox endpoint/event unique key.
CREATE TABLE push_match_queue (
community_id UUID NOT NULL REFERENCES communities(id),
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','matching')),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
lease_until TIMESTAMPTZ,
claim_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, event_id)
);
CREATE INDEX push_match_queue_due
ON push_match_queue (next_attempt_at, created_at) WHERE state = 'pending';
CREATE INDEX push_match_queue_recovery
ON push_match_queue (lease_until) WHERE state = 'matching';
CREATE FUNCTION enqueue_push_match_job() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- Keep this allowlist identical to the relay's validated NIP-PL descriptor.
-- Centralizing it on the events table covers every durable producer,
-- including internal paths that bypass live dispatch.
IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN
INSERT INTO push_match_queue (community_id, event_id)
VALUES (NEW.community_id, NEW.id)
ON CONFLICT DO NOTHING;
END IF;
RETURN NEW;
END
$$;
CREATE TRIGGER events_enqueue_push_match
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION enqueue_push_match_job();
+39
View File
@@ -798,6 +798,45 @@ CREATE INDEX push_wake_outbox_due
ON push_wake_outbox (community_id, next_attempt_at) WHERE state = 'pending';
CREATE INDEX push_wake_outbox_recovery
ON push_wake_outbox (community_id, lease_until) WHERE state = 'sending';
-- Durable event-to-push matching follower. The trigger runs in the event insert
-- transaction, so every accepted persistent event has a crash-safe match job and
-- rejected/rolled-back events never do. Processing is idempotent through the
-- push_wake_outbox endpoint/event unique key.
CREATE TABLE push_match_queue (
community_id UUID NOT NULL REFERENCES communities(id),
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','matching')),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
lease_until TIMESTAMPTZ,
claim_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, event_id)
);
CREATE INDEX push_match_queue_due
ON push_match_queue (next_attempt_at, created_at) WHERE state = 'pending';
CREATE INDEX push_match_queue_recovery
ON push_match_queue (lease_until) WHERE state = 'matching';
CREATE FUNCTION enqueue_push_match_job() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- Keep this allowlist identical to the relay's validated NIP-PL descriptor.
-- Centralizing it on the events table covers every durable producer,
-- including internal paths that bypass live dispatch.
IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN
INSERT INTO push_match_queue (community_id, event_id)
VALUES (NEW.community_id, NEW.id)
ON CONFLICT DO NOTHING;
END IF;
RETURN NEW;
END
$$;
CREATE TRIGGER events_enqueue_push_match
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION enqueue_push_match_job();
-- Durable, deployment-global authority for the public NIP-PL push gateway.
-- This state is intentionally outside relay community tenancy: installations
-- delegate to relay signing keys and may authorize multiple relay deployments.
+11
View File
@@ -14,6 +14,10 @@ BEGIN
WHERE inhparent = 'events'::regclass
AND inhrelid = 'events_p_past'::regclass
) THEN
-- pgschema may copy the parent trigger onto standalone children. Drop
-- that copy before ATTACH; PostgreSQL recreates inherited parent
-- triggers while attaching and rejects a same-named child trigger.
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p_past;
ALTER TABLE events ATTACH PARTITION events_p_past
FOR VALUES FROM (MINVALUE) TO ('2026-01-01');
END IF;
@@ -23,6 +27,7 @@ BEGIN
WHERE inhparent = 'events'::regclass
AND inhrelid = 'events_p2026_01'::regclass
) THEN
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_01;
ALTER TABLE events ATTACH PARTITION events_p2026_01
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
END IF;
@@ -32,6 +37,7 @@ BEGIN
WHERE inhparent = 'events'::regclass
AND inhrelid = 'events_p2026_02'::regclass
) THEN
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_02;
ALTER TABLE events ATTACH PARTITION events_p2026_02
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
END IF;
@@ -41,6 +47,7 @@ BEGIN
WHERE inhparent = 'events'::regclass
AND inhrelid = 'events_p2026_03'::regclass
) THEN
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_03;
ALTER TABLE events ATTACH PARTITION events_p2026_03
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
END IF;
@@ -50,6 +57,7 @@ BEGIN
WHERE inhparent = 'events'::regclass
AND inhrelid = 'events_p2026_04'::regclass
) THEN
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_04;
ALTER TABLE events ATTACH PARTITION events_p2026_04
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
END IF;
@@ -59,6 +67,7 @@ BEGIN
WHERE inhparent = 'events'::regclass
AND inhrelid = 'events_p2026_05'::regclass
) THEN
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_05;
ALTER TABLE events ATTACH PARTITION events_p2026_05
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
END IF;
@@ -68,6 +77,7 @@ BEGIN
WHERE inhparent = 'events'::regclass
AND inhrelid = 'events_p2026_06'::regclass
) THEN
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_06;
ALTER TABLE events ATTACH PARTITION events_p2026_06
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
END IF;
@@ -77,6 +87,7 @@ BEGIN
WHERE inhparent = 'events'::regclass
AND inhrelid = 'events_p_future'::regclass
) THEN
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p_future;
ALTER TABLE events ATTACH PARTITION events_p_future
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
END IF;