Files
buzz/crates/buzz-relay/src/push_runtime.rs
Kalvin C 8a2c9af2db feat(deletion): add durable whole-community deletion (#4425)
## Summary

Adds a durable, operator-controlled V1 for deleting an entire Buzz
community without deleting another tenant's data.

The workflow is exposed through `buzz-admin deletions`:

- `sweep` records independent fleet storage-taxonomy observations
- `submit`, `list`, `inspect`, and `approve` manage a deletion request
- `unblock` resumes a fail-closed request after an operator records
remediation identity and reason
- `run` and `drain` execute bounded work

Requests advance through a PostgreSQL-backed state machine and stop at
`retention_pending` after logical deletion has been independently
verified across PostgreSQL, object storage, and Redis.

This PR ships the engine and CLI, not a continuously running worker or
Kubernetes packaging. For V1, a cluster/VM administrator invokes
`/usr/local/bin/buzz-admin` from the existing relay image, for example
with `kubectl exec` or an equivalent container/VM exec path.

## What whole-community V1 removes

For the target community, V1 removes:

- rows from the allowlisted community-scoped PostgreSQL catalog,
including members, profiles, authored events and bodies, DMs, reactions,
mentions, memberships, tokens, workflows, moderation, audit, feedback,
and rate-limit state
- media sidecars and upload-attribution records under
`_meta/<community>/` and `_uploads/<community>/`
- Git repository pointers under `repos/<community>/`
- Redis keys under `buzz:<community>:*`

The community row survives as a permanent tombstone, and deletion
control-plane records remain as evidence of the request, approval,
execution, and result.

## Safety model

Deletion is not a broad `DELETE CASCADE` followed by optimistic cleanup.
The destructive boundaries are durable and fail closed.

### 1. Inventory and approval

- `submit` resolves the target and freezes the schema plus summary-only
storage inventory.
- Approval is bound to the exact request, community, and frozen
inventory digest.
- Unsupported manifest versions, malformed keys inside the target's
owned prefixes, live scoped-table/write-fence coverage drift,
frozen-inventory mismatch, and approval mismatch block execution rather
than guessing. Migration and catalog revision numbers are not
authorization gates; the executor validates the live safety shape
instead.
- Storage inventory is server-side prefix scoped to exactly:
  - `_meta/<community>/`
  - `_uploads/<community>/`
  - `repos/<community>/`
- The deletion path never lists the whole shared bucket and has no
arbitrary per-community object cap. Its listing work is proportional to
the target community's bindings, not total fleet storage.
- Fleet-wide taxonomy sweeps remain independent observability. They
report unknown writer shapes but do not gate deletion submission,
fencing, or destructive progress. Maintainers must add deletion taxonomy
coverage whenever a new community-owned object-key class is introduced;
writer-coverage tests bind the current media and Git writers to that
contract.

### 2. Quiesce, fence, and destructive freeze

- Writes continue through submission, inventory, and approval. They stop
when execution moves the target into `quiescing` and then establishes
the durable fence.
- Already-admitted external effects finish under heartbeated
serving-write leases; the exact admitted lease may renew while the
community is quiescing, but new lease acquisition is rejected. The
executor drains admitted leases before destructive work.
- Invite minting after quiescing begins fails as typed `AccessDenied`
(HTTP 503 at the relay boundary) before an invite can be persisted.
- Database triggers enforce the community write fence across the
complete catalog of community-scoped tables. Startup/readiness and
destructive execution validate that catalog so a newly added but
unfenced table cannot silently escape.
- **Named isolation assumption — fresh write snapshot.** Every writer
transaction that can reach a community-fenced relation must use
PostgreSQL `READ COMMITTED`; each guarded write therefore observes a
statement snapshot no older than acquisition of the community deletion
lock. `REPEATABLE READ` and `SERIALIZABLE` can retain a pre-fence
snapshot and are unsupported for writers. The writer pool refuses
non-`READ COMMITTED` sessions at connection setup, and both SQL fence
functions reject an explicit per-transaction isolation override with
SQLSTATE `25000`. Configuration-delivered bad isolation can surface
through SQLx as a pool-acquire timeout because every `after_connect`
attempt is rejected; the precise `community writes require READ
COMMITTED isolation` reason remains observable when the SQL guard is
reached. Read-only replica transactions are outside this assumption.
- Holding the shared advisory lock until the guarded write executes is a
separate liveness condition: under `READ COMMITTED`, releasing it early
does not permit resurrection because the trigger rechecks the fence, but
it can turn a fleet sweep into a statement-wide SQLSTATE `55000` abort.
- After the fence closes writers, storage is re-enumerated into chunked
side-table rows. Per-prefix counts and digests bind those concrete keys
to the destructive manifest.
- Manifest chunk insertion, update, and deletion are protected after
freeze. This closes the race where an unbound key could otherwise appear
after the manifest was committed.

### 3. Checkpointed destruction

- Target-owned object bindings are deleted from the frozen destructive
manifest in bounded batches with durable progress.
- The concrete key list lives in chunked side-table rows rather than one
request-row JSON value. It supports large communities, resumable
execution, and terminal cleanup.
- Missing objects are accepted as idempotent crash-window outcomes;
malformed ownership, changed evidence, and unexplained target-prefix
drift fail closed.
- PostgreSQL purging remains scoped by `community_id`, including the
guarded NIP-RS hard-delete path discovered with real Desktop kind
`30078` read-state data.
- Redis cleanup explicitly scans and `UNLINK`s only
`buzz:<community_id>:*`. Natural expiry is insufficient because some
keys, including tunnel generation counters used as fencing state, are
deliberately persistent.

### 4. Independent verification

- PostgreSQL logical absence is checked after purge.
- The three target-owned storage prefixes are freshly inventoried again
and must be empty.
- Redis requires two complete empty namespace scans.
- Only after all three stores pass does the request advance through
`logically_verified` to `retention_pending`.

## What V1 deliberately does not erase

### Shared content-addressed storage

Per-community deletion removes bindings, metadata, attribution records,
and Git pointers. It does **not** physically delete fleet-shared CAS
bytes that another community may still reference:

- media blobs and thumbnails
- Git manifests, packs, and indexes (`manifests/`, `packs/`, and `idx/`)

Safe reclamation requires a separate fleet-wide reachability and
retention GC. Unknown keys elsewhere in the shared bucket do not block
one community's deletion; malformed or unrecognized keys inside that
community's three owned prefixes still fail closed.

### External retained copies

The online logical-deletion proof does not erase object
versions/replicas, database backups/WAL, CDN copies, provider retention
copies, or observability exports. Those require their own retention and
purge controls.

### Member-only erasure

This PR erases a whole community. It does not implement the different
operation "erase one npub while preserving the community."

Removing membership or accepting NIP-09 is not member erasure. A
member-only workflow would need to find and selectively remove or redact
authored event content and pubkeys, profile data, DMs, reactions,
mentions, memberships/roles, tokens, workflows/subscriptions, upload
attribution, moderation/audit history, repository attribution, and
identity embedded in tags or JSON. It would also need explicit rules for
ownership transfer, surviving replies and thread metadata, audit-chain
integrity, immutable Git history, and shared-CAS reachability. That
requires a pubkey-level fence and selective graph rewrite; it is a
separate deletion product, not a safe extension of this whole-tenant
worker.

## In scope

- migration `0029_community_deletion.sql`: requests, approvals, leases,
manifest chunks, checkpoints, tombstones, and the universal write-fence
catalog
- durable executor leases, generations, heartbeats, retry/block state,
and resumable stage transitions
- operator-driven `sweep`, `submit`, `list`, `inspect`, `approve`,
`unblock`, `run`, and `drain` commands
- serving-path fences for database writes and external effects across
event ingest, media, Git, workflow, push, invites, mesh/tunnel, and
related paths
- target-prefix-only storage inventory, summary manifests, post-fence
destructive chunks, and bounded batch deletion
- exact community Redis namespace purge and two-pass absence
verification
- cross-community isolation, crash/resume, manifest-integrity,
writer-taxonomy, and schema/migration regressions
- desired-state `schema/schema.sql` support without requiring a SQLx
migration ledger

## Deferred / not covered

- dedicated Helm/chart worker Deployment, service account, secrets,
probes, resources, and network policy
- autonomous `buzz-admin deletions worker` poll loop and worker-only
health server
- least-privilege separation among migration, relay-serving, and
destructive execution roles
- fleet-wide shared-CAS physical GC
- backup/provider/CDN/observability retention completion
- member-only erasure
- provider-native conditional-delete improvements
- a general force-continue escape hatch; permanent safety failures
remain fail closed unless an operator remediates the cause and records
an audited `unblock`

The removed continuous-worker implementation remains deferred; no remote
follow-up branch is claimed by this PR.

## Validation

### Current PR head and repository state

Current pushed head: `359d8402ee15f049768f54156f67b953c7a7e2ed`, rebased
onto `cc9a2f783375e51a6e8d1f2f9d01d5f7e22813d1` (`origin/main` at push
time). The complete PR diff is now 47 files, 9,834 additions, and 517
deletions.

The bespoke source-scanner stack was removed to keep this PR scoped to
community deletion. Tyler/team requested the underlying fenced-write
safety behavior, not `ast-grep`,
`crates/buzz-db/tests/community_fenced_writes.rs`, its 27 fixtures, or
the new `scripts/lints/community_*.yml` rules. Those scanner-specific
files, dependencies, Hermit links, and runner wiring are absent from the
current tree. The production database write fence, startup/destructive
live-catalog validation, and deletion behavior remain.

Source validation on this exact SHA passed:

- `cargo fmt --all -- --check`
- `bash -n scripts/run-tests.sh`
- `cargo nextest run -p buzz-db --all-targets`: 102 passed, 173 skipped,
0 failed
- `cargo nextest run -p buzz-deletion --all-targets`: 10 passed, 9
skipped, 0 failed
- `cargo nextest run -p buzz-admin --all-targets`: 1 passed, 0 failed
- affected-package/all-target Clippy with warnings denied
- lockfile consistency
- Helm 3.16.4 lint and all 44 chart unit tests
- Helm region controls using that fixture: default
`BUZZ_S3_REGION=us-east-1`, explicit `eu-west-2` override, and
blank-region schema rejection

The prior Kubernetes battery below was run against
`928992237358a3294621ac0280830b77155abc04`. It remains useful evidence
for the patch-equivalent production deletion implementation, but it is
**not** claimed as exact-SHA evidence for current head
`359d8402ee15f049768f54156f67b953c7a7e2ed`; the current cleanup removes
only scanner/test/tooling infrastructure. CI restarted for the new head
after the rebase and is pending. Human review remains
`CHANGES_REQUESTED`.

### Prior-head live Kubernetes deletion and safety gates

The full program used one immutable image, real PostgreSQL, Redis,
MinIO, and a three-relay Kubernetes release:

- source: `928992237358a3294621ac0280830b77155abc04` (**prior head**)
- image: `buzz-e2e:sha-928992237358`
- immutable image digest:
`sha256:a1a204f4618ac22d9e210be5e5290645a15d79831ae30b0e44379357c8e4a895`
- evidence root:
`/tmp/buzz-e2e/20260807T033025Z-928992237358-full-gates/`
- evidence-manifest digest:
`82875c5bc9bea7370b796a7aef3457b3a1c8306c84c59e0f7388bbb5ad30e865`

Passed gates at that prior head:

- **Chart/operator region:** default `us-east-1`, explicit nondefault
propagation, blank-region schema rejection, live in-pod environment, and
an in-pod taxonomy sweep over 18 objects with zero unknown.
- **Fenced writers and lifecycle:** open-write/fence ordering;
100-attempt anti-starvation; invite, push matcher, and exhausted-reaper
bystander isolation; non-`READ-COMMITTED` rejection; manifest/tombstone
contracts; eight-failure stage block and audited `unblock`.
- **Destructive lifecycle:** submit → approve → run →
`retention_pending`; PostgreSQL tombstone and Redis/S3 verification
true; zero retries/errors; terminal reruns rejected with exit 5.
- **Fresh 10,001-object crash boundary:** exactly two chunks (10,000 +
1). The executor deleted chunk 0 from MinIO while its PostgreSQL stamp
was row-lock-blocked, was killed with `SIGKILL`, left one object and
both stamps absent, then resumed the same request under generation 2 to
zero objects and terminal state.
- **Independent dead-owner recovery:** a dedicated executor claimed
generation 1, blocked before effects, and was killed through containerd
with `SIGKILL` (no TERM cleanup). The request remained owned and
unreclaimable before lease expiry; a successor claimed generation 2
after 60 seconds and completed with two attempts and zero retries.
- **Three-pod socket isolation:** ordinary NIP-42 and joined
huddle-audio target witnesses on every replica received exact `1008 /
community deleted`; healthy-tenant witnesses on those pods remained
live; deleted-host reconnect returned HTTP 404.
- **Health/provenance:** all replicas independently returned ready and
retained the exact image digest before/after destructive runs and an
audio-enabled rolling restart; PostgreSQL, Redis, and MinIO were healthy
at close.

Instrument corrections were retained as evidence rather than counted as
product failures: a foreground PostgreSQL forward caused an initial
`PoolTimedOut`; Kubernetes pod deletion exercised graceful TERM rather
than dead-owner recovery; shell-background socket witnesses died with
their parent; and the first image build hit the corporate TLS proxy.
Detached forwarding/witnesses, containerd `SIGKILL`, and the configured
internal CA/Artifactory mirror produced the discriminating runs without
weakening product security.

### Prior-head cleanup

For the prior-head Kubernetes run, the Helm release was removed,
namespace absence was verified, run-owned Screen sessions were absent,
and that source worktree remained clean. The evidence manifest was
independently recomputed and every indexed artifact passed `shasum -a
256 -c`. The current `359d8402` source worktree is also clean after the
scanner-only cleanup and push.

---------

Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Signed-off-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
2026-08-12 09:25:58 -07:00

690 lines
24 KiB
Rust

//! 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;
/// Upper bound on one claimed matcher batch. Bounded well under
/// `get_events_by_ids`' 500-id batch-fetch contract.
const MATCH_BATCH_LIMIT: i64 = 64;
/// Idle poll floor and ceiling. An idle matcher previously issued a claim
/// transaction every 250ms forever; backing off to the ceiling folds that
/// steady-state cost while keeping first-message latency at the floor.
const IDLE_POLL_FLOOR: Duration = Duration::from_millis(250);
const IDLE_POLL_CEILING: Duration = Duration::from_secs(2);
/// Cadence of the poison-job sweep, which lives off the claim path: its scan
/// is not served by the due partial index, so running it inside every claim
/// made claims slower exactly when a backlog needed them fastest.
const REAP_INTERVAL: Duration = Duration::from_secs(30);
#[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 in per-community batches and match
/// them against active leases (T2b). One batch costs one claim statement,
/// one event load, one lease scan, one membership scan, and one wake-enqueue
/// transaction — plus one complete/retry statement each — regardless of
/// batch size or how many (event, lease) pairs match.
pub async fn run_matcher(state: Arc<AppState>) {
let mut idle_delay = IDLE_POLL_FLOOR;
let mut last_reap = tokio::time::Instant::now();
loop {
if last_reap.elapsed() >= REAP_INTERVAL {
match state.db.reap_exhausted_push_matches().await {
Ok(reaped) if reaped > 0 => warn!(reaped, "reaped exhausted push match jobs"),
Ok(_) => {}
Err(e) => error!("push match reap failed: {e}"),
}
last_reap = tokio::time::Instant::now();
}
let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS);
match state
.db
.claim_due_push_match_batch(MATCH_BATCH_LIMIT, until)
.await
{
Ok(Some(batch)) => {
idle_delay = IDLE_POLL_FLOOR;
process_match_batch(&state, batch).await;
}
Ok(None) => {
tokio::time::sleep(idle_delay).await;
idle_delay = (idle_delay * 2).min(IDLE_POLL_CEILING);
}
Err(e) => {
error!("push matcher claim failed: {e}");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
/// Per-batch state shared by every job: the community's active leases and
/// the exact (channel, lease author) membership pairs the jobs can consult.
struct MatchContext {
leases: Vec<buzz_db::push::MatchLease>,
memberships: std::collections::HashSet<(uuid::Uuid, Vec<u8>)>,
}
async fn load_match_context(
state: &AppState,
batch: &buzz_db::push::ClaimedMatchBatch,
) -> anyhow::Result<MatchContext> {
let leases = state.db.active_push_match_leases(batch.community).await?;
let mut channels: Vec<uuid::Uuid> = batch
.jobs
.iter()
.filter_map(|job| job.event.channel_id)
.collect();
channels.sort_unstable();
channels.dedup();
let mut authors: Vec<Vec<u8>> = leases.iter().map(|lease| lease.author.clone()).collect();
authors.sort_unstable();
authors.dedup();
let memberships = state
.db
.membership_pairs(batch.community, &channels, &authors)
.await?
.into_iter()
.collect();
Ok(MatchContext {
leases,
memberships,
})
}
async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatchBatch) {
let community = batch.community;
let context = match load_match_context(state, &batch).await {
Ok(context) => context,
Err(e) => {
// Shared context failed to load, so no job was evaluated: release
// the whole batch for retry. Jobs that keep failing are reaped by
// the periodic sweep once their attempts are exhausted.
warn!(%community, "push match context load failed: {e}");
let ids: Vec<Vec<u8>> = batch
.jobs
.iter()
.map(|job| job.event.event.id.as_bytes().to_vec())
.collect();
if let Err(e) = state
.db
.retry_push_match_batch(
community,
batch.claim_id,
&ids,
Utc::now() + TimeDelta::seconds(2),
)
.await
{
warn!(%community, "push match batch retry failed: {e}");
}
return;
}
};
let mut completed = Vec::new();
let mut retry = Vec::new();
// Jobs whose wakes are pending the set-wise enqueue below: their
// completion is decided by the flush, not by match evaluation.
let mut pending = Vec::new();
let mut wakes: Vec<buzz_db::push::WakeRequest> = Vec::new();
for job in &batch.jobs {
let event_id = job.event.event.id.as_bytes().to_vec();
match match_job(job, &context) {
Ok(job_wakes) if job_wakes.is_empty() => completed.push(event_id),
Ok(job_wakes) => {
pending.push((event_id, job.attempt));
wakes.extend(job_wakes);
}
Err(e) => {
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.
completed.push(event_id);
} else {
retry.push(event_id);
}
}
}
}
// One transaction for every wake in the batch (T2b). InactiveLease and
// Duplicate outcomes are per-request non-errors; only a failed enqueue
// transaction sends the contributing jobs back for an idempotent rematch
// (the outbox dedup key absorbs any wakes that did commit elsewhere).
match state.db.enqueue_push_wakes(community, &wakes).await {
Ok(_) => completed.extend(pending.into_iter().map(|(event_id, _)| event_id)),
Err(e) => {
warn!(%community, "push wake batch enqueue failed: {e}");
for (event_id, attempt) in pending {
if attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS {
completed.push(event_id);
} else {
retry.push(event_id);
}
}
}
}
if let Err(e) = state
.db
.complete_push_match_batch(community, batch.claim_id, &completed)
.await
{
warn!(%community, "push match batch completion failed: {e}");
}
if let Err(e) = state
.db
.retry_push_match_batch(
community,
batch.claim_id,
&retry,
Utc::now() + TimeDelta::seconds(2),
)
.await
{
warn!(%community, "push match batch retry failed: {e}");
}
}
/// Pure match evaluation: no DB access. Returns the wake requests this job
/// owes, which the caller flushes set-wise for the whole batch.
fn match_job(
job: &buzz_db::push::BatchedMatch,
context: &MatchContext,
) -> anyhow::Result<Vec<buzz_db::push::WakeRequest>> {
let mut wakes = Vec::new();
for lease in &context.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 !context
.memberships
.contains(&(channel, lease.author.clone()))
{
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;
}
wakes.push(buzz_db::push::WakeRequest {
author: lease.author.clone(),
installation_id: lease.installation_id.clone(),
lease_generation: lease.generation,
event_id: job.event.event.id.as_bytes().to_vec(),
class: class.to_string(),
expires_at,
});
}
Ok(wakes)
}
/// 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");
let mut idle_delay = Duration::from_millis(500);
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 {
idle_delay = Duration::from_millis(500);
} else {
// Empty sweeps back off so an idle worker stops paying a full
// per-community claim scan every 500ms forever.
tokio::time::sleep(idle_delay).await;
idle_delay = (idle_delay * 2).min(IDLE_POLL_CEILING);
}
}
}
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 serving_write = match buzz_deletion::acquire_serving_write(
&state.db,
outcome.community,
"push_delivery",
)
.await
{
Ok(guard) => guard,
Err(error) => {
warn!(wake=%outcome.id, %error, "push delivery suppressed by community deletion fence");
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
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;
}
};
if let Err(error) = serving_write.verify().await {
warn!(wake=%outcome.id, %error, "push serving lease lost before delivery");
return;
}
let response = match serving_write
.protect(send_gateway_request(http, url, body, auth))
.await
{
Ok(response) => response,
Err(error) => {
warn!(wake=%outcome.id, %error, "push serving lease lost during delivery");
return;
}
};
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;
}
}
if let Err(error) = serving_write.finish().await {
warn!(wake=%outcome.id, %error, "failed to release community serving lease after push delivery");
}
}
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());
}
}