mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user