feat(relay): deliver pubkey mentions to relay companions (#7793)

We are introducing a companion service to the relay that needs pubkey
event mentions for pubkeys it manages. This PR introduces an outbox for
pubkey mentions that will be sent to these "operator listener" services.

## Summary

Deliver mentions of registered pubkeys to deployment-global operator
listeners.

- Configure listeners with `BUZZ_OPERATOR_LISTENERS`; listeners manage
target pubkeys through NIP-98-authenticated `POST`/`DELETE
/operator/listener/pubkeys` requests.
- During supported event ingestion, match `p` tags to registrations and
insert one outbox row per listener in the event transaction.
- A background worker claims up to 10 rows, sends NIP-98-signed
notifications, and retries transient failures with exponential backoff
for up to 9 attempts (idle polling backs off from 250 ms to 2 s).
- Outbox rows are ineligible for delivery after 15 minutes and are
reaped every 5 minutes. Registered pubkeys live for 30 days;
re-registering renews them, and a daily reaper removes expired
registrations.
- Keep persistence app-managed: no database triggers, functions, foreign
keys, or separate match queue.

## Changed behavior

Listener outbox rows are enqueued inside the shared event-insert and
thread-metadata transactions; enqueue failure rolls back the event.
`event_mentions` behavior is unchanged: ordinary event/reaction mention
indexing remains post-commit best effort with warning-only failures.

## Testing

- `cargo fmt --all`
- `cargo test -p buzz-db --lib`
- `cargo test -p buzz-relay --lib
config::tests::operator_listener_routes`
- `cargo check -p buzz-db -p buzz-relay`
This commit is contained in:
Jarrod Sibbison
2026-09-28 13:04:40 +10:00
committed by GitHub
parent b0d6fb8ad2
commit c4c86006f2
18 changed files with 2587 additions and 46 deletions

View File

@@ -490,6 +490,8 @@ mod postgres_tests {
"relay_admin_outbox",
"relay_operator_audit",
"storage_accounting_snapshots",
"operator_listener_pubkeys",
"operator_listener_outbox",
] {
if normalized[insert_pos..].contains(&format!("'{value}'")) {
globals.insert(value.to_owned());
@@ -703,7 +705,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 49);
assert_eq!(migrations.len(), 50);
assert_eq!(migrations[48].version, 49);
assert!(migrations[48]
.sql
@@ -1293,10 +1295,63 @@ mod postgres_tests {
.sql
.as_str()
.contains("CREATE TABLE storage_accounting_snapshots"));
// schema.sql exclusion list must match the restored (pre-0041) body.
assert_eq!(migrations[47].version, 48);
assert!(migrations[47]
.sql
.as_str()
.contains("ADD COLUMN hash_version SMALLINT"));
assert_eq!(migrations[49].version, 50);
assert!(migrations[49]
.sql
.as_str()
.contains("CREATE TABLE operator_listener_pubkeys"));
assert!(migrations[49]
.sql
.as_str()
.contains("CREATE TABLE operator_listener_outbox"));
assert!(migrations[49]
.sql
.as_str()
.contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table"));
assert!(migrations[49]
.sql
.as_str()
.contains("'rate_limit_violations', 'operator_listener_outbox'"));
for index in [
"operator_listener_pubkeys_target",
"operator_listener_pubkeys_created_at",
"operator_listener_outbox_due",
"operator_listener_outbox_recovery",
"operator_listener_outbox_created_at",
] {
assert!(
migrations[49].sql.as_str().contains(index),
"migration 0050 must declare {index}"
);
assert!(
desired_schema.contains(index),
"schema.sql must declare {index}"
);
}
for index_shape in [
"ON operator_listener_outbox (next_attempt_at, created_at, id)",
"ON operator_listener_outbox (lease_until, created_at, id)",
] {
assert!(
migrations[49].sql.as_str().contains(index_shape),
"migration 0050 must declare {index_shape}"
);
assert!(
desired_schema.contains(index_shape),
"schema.sql must declare {index_shape}"
);
}
// schema.sql keeps the restored (pre-0041) deletion exclusions and also
// leaves the deployment-global listener outbox outside tenant fencing.
assert!(
desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"),
"schema.sql exclusion list must match the pre-0041 body after ledger removal"
desired_schema
.contains("'rate_limit_violations', 'operator_listener_outbox'\n ]::TEXT[])"),
"schema.sql must exclude the deployment-global listener outbox from tenant fencing"
);
}