feat(relay): operator write-allowlist (owner/announcements exemption)

RELAY_WRITE_ALLOWLIST_NPUBS (npub1… or hex, comma-separated): listed authors may
publish ANY kind regardless of write policy or premium status. The use case is a
feed-owner / announcements account that seeds a premium-post feed without holding
a subscription — the admission engine previously had no owner/allowlist bypass
(FEED_OWNER_NPUB is read-side only).

- policy.rs: decide() gains author_allowlisted, checked first (permit-any).
- mod.rs: RelayProvider::is_write_allowlisted; entries decoded npub→hex once at
  boot (bech32), malformed entries logged + skipped. npub-decode KAT.
- nauthz.rs: resolves the flag (in-memory set, no DB) and short-circuits the
  registration/premium/recipient lookups when exempt.
- .env.example: document the var + the premium-post policy.

Relay tests 21/21.
This commit is contained in:
jwinterm
2026-07-07 04:56:55 -04:00
parent 00c0f9302e
commit 2f248c2c3d
5 changed files with 201 additions and 40 deletions
+7
View File
@@ -151,10 +151,17 @@ RELAY_URL=
# deliver a gift-wrapped DM (kind 1059) to a registered user.
# author-allowlist — only registered npubs may publish anything.
# open — accept everything (relay resource caps still apply).
# premium-post — registered users post wallet events (DMs) free; general
# notes require an active PREMIUM_* subscription.
RELAY_WRITE_POLICY=inbox-outbox
# NIP-13 proof-of-work bits required on cross-ecosystem (non-registered-author)
# inbound events; 0 disables the PoW gate.
RELAY_INBOUND_POW_BITS=0
# Write-exempt npubs (comma-separated; npub1… or 64-char hex): may publish ANY
# kind regardless of the write policy or premium status. Use this for an
# announcements / feed-owner account that seeds a premium-post feed without
# holding a subscription. Empty = no exemptions.
RELAY_WRITE_ALLOWLIST_NPUBS=
# Loopback address the gRPC event-admission service binds (the relay calls it).
# It answers "is this npub registered?" (a registration oracle) so it MUST stay
# loopback; the bundled compose reaches it via the host network.
+9
View File
@@ -639,6 +639,12 @@ pub struct RelayConfig {
pub max_event_bytes: usize,
/// Event retention (days) — advisory for operator housekeeping/advertising.
pub retention_days: u32,
/// Write-exempt npubs (hex or `npub1…`): may publish ANY kind regardless of
/// the write policy or premium status. The operator's use case is an
/// announcements / feed-owner account that seeds a `premium-post` feed without
/// itself holding a subscription. Empty by default. Configured via
/// `RELAY_WRITE_ALLOWLIST_NPUBS` (comma-separated).
pub write_allowlist: Vec<String>,
}
/// Public curated feed (`feed.<domain>`) — an operator knob for what the
@@ -858,6 +864,7 @@ impl Config {
admission_allow_public: env_bool("RELAY_ADMISSION_ALLOW_PUBLIC", false),
max_event_bytes: env_parse("RELAY_MAX_EVENT_BYTES", 65536usize)?,
retention_days: env_parse("RELAY_RETENTION_DAYS", 30u32)?,
write_allowlist: env_list("RELAY_WRITE_ALLOWLIST_NPUBS"),
},
},
premium: PremiumConfig {
@@ -1419,6 +1426,7 @@ mod tests {
admission_allow_public: false,
max_event_bytes: 65536,
retention_days: 30,
write_allowlist: Vec::new(),
},
},
premium: PremiumConfig {
@@ -1738,6 +1746,7 @@ mod tests {
admission_allow_public: false,
max_event_bytes: 65536,
retention_days: 30,
write_allowlist: Vec::new(),
}
}
+97 -11
View File
@@ -16,6 +16,7 @@ mod policy;
pub use policy::{decide, leading_zero_bits, Admit, EventMeta, WritePolicy, GIFT_WRAP_KIND};
use std::collections::HashSet;
use std::sync::Arc;
use crate::config::Config;
@@ -41,12 +42,44 @@ pub trait RelayProvider: Send + Sync {
/// Max event size (bytes) the admission service enforces as defence-in-depth
/// (the relay enforces its own limit too).
fn max_event_bytes(&self) -> usize;
/// Whether `author_hex` (canonical lowercase x-only pubkey hex) is on the
/// operator write-allowlist — may publish any kind regardless of policy or
/// premium. Default: never. See `RELAY_WRITE_ALLOWLIST_NPUBS`.
fn is_write_allowlisted(&self, _author_hex: &str) -> bool {
false
}
/// NIPs advertised as supported.
fn supported_nips(&self) -> &'static [u16] {
SUPPORTED_NIPS
}
}
/// Normalize a write-allowlist entry (`npub1…` or 64-char hex) to canonical
/// lowercase x-only pubkey hex, or `None` if it isn't a valid pubkey. npubs are
/// bech32-decoded; hex is validated + lowercased. Keeps the admission comparison
/// (which is hex) operator-friendly (npubs) without a hex-only requirement.
fn allowlist_entry_to_hex(entry: &str) -> Option<String> {
let s = entry.trim();
if s.is_empty() {
return None;
}
if s.starts_with("npub1") {
// bech32 npub → 32-byte x-only pubkey → hex.
let (hrp, data) = bech32::decode(s).ok()?;
if hrp.as_str() != "npub" || data.len() != 32 {
return None;
}
return Some(hex::encode(data));
}
// Bare hex (x-only = 32 bytes = 64 chars).
let lower = s.to_lowercase();
if lower.len() == 64 && hex::decode(&lower).is_ok() {
Some(lower)
} else {
None
}
}
/// Config-selected relay provider, or `None` when the relay is disabled.
/// Mirrors [`crate::infra::payment::from_config`].
pub fn from_config(cfg: &Config) -> Result<Option<Arc<dyn RelayProvider>>, AppError> {
@@ -63,17 +96,34 @@ pub fn from_config(cfg: &Config) -> Result<Option<Arc<dyn RelayProvider>>, AppEr
// From the backend's POV, `bundled` and `external` are the same: a relay
// reachable at `advertised_url` that we advertise + admit for. They differ
// only in who runs the process (packaging), a deploy concern.
"bundled" | "external" => Arc::new(NostrRelayProvider {
kind: if r.mode == "bundled" {
"nostr-rs-relay"
} else {
"external"
},
advertised_url: r.advertised_url.clone(),
policy,
inbound_pow_bits: r.inbound_pow_bits,
max_event_bytes: r.max_event_bytes,
}),
"bundled" | "external" => {
// Decode the operator write-allowlist to canonical hex once, at
// startup. A malformed entry is logged + skipped rather than failing
// the whole boot (the relay still works; that npub just isn't exempt).
let mut write_allowlist: HashSet<String> = HashSet::new();
for entry in &r.write_allowlist {
match allowlist_entry_to_hex(entry) {
Some(hex) => {
write_allowlist.insert(hex);
}
None => tracing::warn!(
"RELAY_WRITE_ALLOWLIST_NPUBS: skipping invalid entry {entry:?}"
),
}
}
Arc::new(NostrRelayProvider {
kind: if r.mode == "bundled" {
"nostr-rs-relay"
} else {
"external"
},
advertised_url: r.advertised_url.clone(),
policy,
inbound_pow_bits: r.inbound_pow_bits,
max_event_bytes: r.max_event_bytes,
write_allowlist,
})
}
other => {
return Err(AppError::ConfigError(format!(
"unsupported RELAY_MODE: {other}"
@@ -91,6 +141,39 @@ struct NostrRelayProvider {
policy: WritePolicy,
inbound_pow_bits: u8,
max_event_bytes: usize,
/// Canonical-hex write-exempt pubkeys (decoded from config npubs/hex).
write_allowlist: HashSet<String>,
}
#[cfg(test)]
mod tests {
use super::allowlist_entry_to_hex;
// Canonical NIP-19 test vector.
const NPUB: &str = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
const HEX: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
#[test]
fn decodes_npub_to_hex() {
assert_eq!(allowlist_entry_to_hex(NPUB).as_deref(), Some(HEX));
}
#[test]
fn accepts_bare_hex_and_lowercases() {
assert_eq!(allowlist_entry_to_hex(HEX).as_deref(), Some(HEX));
assert_eq!(
allowlist_entry_to_hex(&format!(" {} ", HEX.to_uppercase())).as_deref(),
Some(HEX),
);
}
#[test]
fn rejects_junk() {
assert_eq!(allowlist_entry_to_hex(""), None);
assert_eq!(allowlist_entry_to_hex("not-a-key"), None);
assert_eq!(allowlist_entry_to_hex("npub1garbage"), None);
assert_eq!(allowlist_entry_to_hex("deadbeef"), None); // too short
}
}
impl RelayProvider for NostrRelayProvider {
@@ -109,4 +192,7 @@ impl RelayProvider for NostrRelayProvider {
fn max_event_bytes(&self) -> usize {
self.max_event_bytes
}
fn is_write_allowlisted(&self, author_hex: &str) -> bool {
!self.write_allowlist.is_empty() && self.write_allowlist.contains(author_hex)
}
}
+24 -12
View File
@@ -73,26 +73,37 @@ impl Authorization for AdmissionService {
let author_hex = hex::encode(&event.pubkey);
let id_hex = hex::encode(&event.id);
// Operator write-allowlist (owner/announcements) — a pure in-memory set
// check, no DB. If exempt, `decide` permits any kind immediately, so we
// can skip the registration/premium/recipient lookups below.
let author_allowlisted = self.relay.is_write_allowlisted(&author_hex);
// Author registration.
let author_registered = self
.db
.is_registered_npub(&author_hex)
.await
.unwrap_or(false);
let author_registered = if author_allowlisted {
false
} else {
self.db
.is_registered_npub(&author_hex)
.await
.unwrap_or(false)
};
// Premium membership — only the `premium-post` policy consults it, so skip
// the (cheap, indexed) lookup entirely under the other policies.
let author_premium = if self.relay.write_policy() == WritePolicy::PremiumPost {
// the (cheap, indexed) lookup entirely under the other policies (and when
// the author is already write-allowlisted).
let author_premium = if !author_allowlisted
&& self.relay.write_policy() == WritePolicy::PremiumPost
{
self.db.is_premium_npub(&author_hex).await.unwrap_or(false)
} else {
false
};
// Recipient registration (only when the author isn't already registered):
// collect up to MAX_P_TAGS distinct `p` tags and resolve them in ONE
// batched query — bounds the work so a crafted event stuffed with `p` tags
// can't trigger an unbounded per-tag query storm.
let recipient_registered = if author_registered {
// Recipient registration (only when the author isn't already registered /
// allowlisted): collect up to MAX_P_TAGS distinct `p` tags and resolve them
// in ONE batched query — bounds the work so a crafted event stuffed with
// `p` tags can't trigger an unbounded per-tag query storm.
let recipient_registered = if author_registered || author_allowlisted {
false
} else {
const MAX_P_TAGS: usize = 20;
@@ -123,6 +134,7 @@ impl Authorization for AdmissionService {
author_registered,
recipient_registered,
author_premium,
author_allowlisted,
) {
Admit::Permit => permit(),
Admit::Deny(msg) => deny(msg),
+64 -17
View File
@@ -119,8 +119,10 @@ fn giftwrap_inbox(meta: &EventMeta, inbound_pow_bits: u8, recipient_registered:
/// `author_registered` = the author pubkey is a registered Smirk npub.
/// `recipient_registered` = at least one `p` tag is a registered Smirk npub.
/// `author_premium` = the author holds an active premium subscription (only the
/// `premium-post` policy consults it). The caller resolves all three against the
/// DB before calling.
/// `premium-post` policy consults it).
/// `author_allowlisted` = the author is on the operator's write-allowlist
/// (`RELAY_WRITE_ALLOWLIST_NPUBS`) — e.g. the announcements / feed-owner account.
/// The caller resolves all of these before calling.
pub fn decide(
meta: &EventMeta,
policy: WritePolicy,
@@ -128,7 +130,14 @@ pub fn decide(
author_registered: bool,
recipient_registered: bool,
author_premium: bool,
author_allowlisted: bool,
) -> Admit {
// Operator write-allowlist: an owner/announcements npub may publish ANY kind
// regardless of policy or premium. Checked first so the owner can seed a
// premium-post feed (and post announcements) without a subscription.
if author_allowlisted {
return Admit::Permit;
}
match policy {
WritePolicy::Open => Admit::Permit,
WritePolicy::AuthorAllowlist => {
@@ -204,38 +213,38 @@ mod tests {
#[test]
fn open_permits_everything() {
let m = meta(A, 1, FF);
assert!(decide(&m, WritePolicy::Open, 0, false, false, false).is_permit());
assert!(decide(&m, WritePolicy::Open, 0, false, false, false, false).is_permit());
}
#[test]
fn author_allowlist_gates_on_author() {
let m = meta(A, 1, FF);
assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false, false).is_permit());
assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false, false).is_permit());
assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false, false, false).is_permit());
assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false, false, false).is_permit());
}
#[test]
fn inbox_outbox_permits_registered_author_outbox() {
let m = meta(A, 1, FF); // any kind, registered author
assert!(decide(&m, WritePolicy::InboxOutbox, 0, true, false, false).is_permit());
assert!(decide(&m, WritePolicy::InboxOutbox, 0, true, false, false, false).is_permit());
}
#[test]
fn inbox_outbox_permits_giftwrap_to_registered_recipient() {
let m = meta(A, GIFT_WRAP_KIND, FF);
assert!(decide(&m, WritePolicy::InboxOutbox, 0, false, true, false).is_permit());
assert!(decide(&m, WritePolicy::InboxOutbox, 0, false, true, false, false).is_permit());
}
#[test]
fn inbox_outbox_rejects_external_non_giftwrap() {
let m = meta(A, 1, FF); // external author, non-gift-wrap
assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, true, false).is_permit());
assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, true, false, false).is_permit());
}
#[test]
fn inbox_outbox_rejects_giftwrap_to_unregistered_recipient() {
let m = meta(A, GIFT_WRAP_KIND, FF);
assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, false, false).is_permit());
assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, false, false, false).is_permit());
}
#[test]
@@ -252,8 +261,8 @@ mod tests {
let id = format!("0000{}", "ff".repeat(30));
let m = meta(A, GIFT_WRAP_KIND, &id);
// require 8 bits → permit; require 20 → deny.
assert!(decide(&m, WritePolicy::InboxOutbox, 8, false, true, false).is_permit());
assert!(!decide(&m, WritePolicy::InboxOutbox, 20, false, true, false).is_permit());
assert!(decide(&m, WritePolicy::InboxOutbox, 8, false, true, false, false).is_permit());
assert!(!decide(&m, WritePolicy::InboxOutbox, 20, false, true, false, false).is_permit());
}
#[test]
@@ -261,7 +270,7 @@ mod tests {
// A registered author's low-PoW event is still fine (PoW is inbound-only).
let id = "ff".repeat(32); // 0 leading zero bits
let m = meta(A, 1, &id);
assert!(decide(&m, WritePolicy::InboxOutbox, 20, true, false, false).is_permit());
assert!(decide(&m, WritePolicy::InboxOutbox, 20, true, false, false, false).is_permit());
}
// ── premium-post ─────────────────────────────────────────────────────────
@@ -270,7 +279,7 @@ mod tests {
fn premium_post_premium_author_posts_anything() {
let m = meta(A, 1, FF); // a general kind-1 note
// premium → permit even a non-wallet kind.
assert!(decide(&m, WritePolicy::PremiumPost, 0, true, false, true).is_permit());
assert!(decide(&m, WritePolicy::PremiumPost, 0, true, false, true, false).is_permit());
}
#[test]
@@ -279,7 +288,7 @@ mod tests {
let m = meta(A, *k, FF);
// registered, NOT premium → wallet kinds still permitted.
assert!(
decide(&m, WritePolicy::PremiumPost, 0, true, false, false).is_permit(),
decide(&m, WritePolicy::PremiumPost, 0, true, false, false, false).is_permit(),
"wallet kind {k} should be free for registered users"
);
}
@@ -288,19 +297,57 @@ mod tests {
#[test]
fn premium_post_registered_needs_premium_for_general() {
let m = meta(A, 1, FF); // general kind-1, registered but not premium
assert!(!decide(&m, WritePolicy::PremiumPost, 0, true, false, false).is_permit());
assert!(!decide(&m, WritePolicy::PremiumPost, 0, true, false, false, false).is_permit());
}
#[test]
fn premium_post_allows_external_giftwrap_inbox() {
let m = meta(A, GIFT_WRAP_KIND, FF);
// external (unregistered) author delivering a DM to a registered user.
assert!(decide(&m, WritePolicy::PremiumPost, 0, false, true, false).is_permit());
assert!(decide(&m, WritePolicy::PremiumPost, 0, false, true, false, false).is_permit());
}
#[test]
fn premium_post_rejects_external_general() {
let m = meta(A, 1, FF); // external author, non-gift-wrap
assert!(!decide(&m, WritePolicy::PremiumPost, 0, false, true, false).is_permit());
assert!(!decide(&m, WritePolicy::PremiumPost, 0, false, true, false, false).is_permit());
}
// ── write-allowlist (owner/announcements exemption) ──────────────────────
#[test]
fn allowlisted_author_posts_general_note_under_premium_post() {
// The owner npub is NOT premium and NOT otherwise registered, but is on
// the write-allowlist → may post a general kind-1 note to a premium relay.
let m = meta(A, 1, FF);
assert!(
decide(&m, WritePolicy::PremiumPost, 0, false, false, false, true).is_permit(),
"an allowlisted owner must be able to seed a premium-post feed"
);
}
#[test]
fn allowlist_bypasses_every_policy_and_kind() {
for policy in [
WritePolicy::Open,
WritePolicy::AuthorAllowlist,
WritePolicy::InboxOutbox,
WritePolicy::PremiumPost,
] {
for kind in [1u64, GIFT_WRAP_KIND, 30023] {
let m = meta(A, kind, FF);
assert!(
decide(&m, policy, 8, false, false, false, true).is_permit(),
"allowlisted author should be permitted under {policy:?} for kind {kind}"
);
}
}
}
#[test]
fn non_allowlisted_still_gated() {
// Sanity: with allowlisted=false the premium gate still bites.
let m = meta(A, 1, FF);
assert!(!decide(&m, WritePolicy::PremiumPost, 0, true, false, false, false).is_permit());
}
}