feat(feed): operator-configurable public feed advertised via /capabilities

A read-only web feed (feed.<domain>) is a backend-operator knob: FEED_ENABLED
plus curation switches (show owner / show premium members / a featured npub
allowlist / extra relays) surface in /capabilities.feed so the feed page adapts
per instance. Reads from the relay, so validate() fail-closes FEED_ENABLED
without RELAY_ENABLED. Off by default; all public display config, no secrets.
This commit is contained in:
jwinterm
2026-07-03 16:58:22 -04:00
parent a166ef56d7
commit ef6d280412
5 changed files with 170 additions and 0 deletions
+41
View File
@@ -49,6 +49,8 @@ pub struct FeatureCapabilities {
pub nostr_relay: bool,
/// Paid premium tier for general Nostr posting to the relay. See `premium`.
pub premium_relay: bool,
/// Public curated Nostr feed for this instance. See `feed`.
pub feed: bool,
/// Tipping (parked).
pub tips: bool,
}
@@ -88,6 +90,25 @@ pub struct PremiumCapability {
pub relay_url: String,
}
/// Public curated feed details (present only when `features.feed`). A read-only
/// web feed (e.g. `feed.<domain>`) reads from the relay and shows posts per the
/// operator's curation knobs.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct FeedCapability {
/// The relay the feed reads from (mirrors `messaging.relay_url`).
pub relay_url: String,
/// Include the operator's own posts.
pub show_owner: bool,
/// Include premium members' posts (all general notes on the gated relay).
pub show_premium: bool,
/// The operator's npub for owner filtering + display; `null` when unset.
pub owner_npub: Option<String>,
/// Specific featured npubs to surface.
pub allowlist_npubs: Vec<String>,
/// Extra relays to also pull the allowlisted authors from.
pub extra_relays: Vec<String>,
}
/// This instance's wallet-restore (import) policy. The wallet uses it to adapt
/// its import UX — hide/grey the restore-height field under `create-only`, warn
/// when a chosen date exceeds the bound. `max_depth_days` is present only for
@@ -142,6 +163,9 @@ pub struct CapabilitiesResponse {
/// Premium tier details; present only when `features.premium_relay`.
#[serde(skip_serializing_if = "Option::is_none")]
pub premium: Option<PremiumCapability>,
/// Public curated feed details; present only when `features.feed`.
#[serde(skip_serializing_if = "Option::is_none")]
pub feed: Option<FeedCapability>,
}
/// Whether an enabled chain can actually be served (its infra secret/URL is
@@ -202,6 +226,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
// presence downgrade — never advertise a relay clients can't reach).
nostr_relay: relay_advertised(config),
premium_relay: premium_advertised(config),
feed: feed_advertised(config),
tips: config.features.tips,
},
restore: RestoreCapability {
@@ -252,6 +277,17 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
.collect(),
relay_url: config.messaging.relay.advertised_url.clone(),
}),
feed: feed_advertised(config).then(|| {
let f = &config.feed;
FeedCapability {
relay_url: config.messaging.relay.advertised_url.clone(),
show_owner: f.show_owner,
show_premium: f.show_premium,
owner_npub: (!f.owner_npub.is_empty()).then(|| f.owner_npub.clone()),
allowlist_npubs: f.allowlist_npubs.clone(),
extra_relays: f.extra_relays.clone(),
}
}),
}
}
@@ -268,6 +304,11 @@ fn premium_advertised(config: &Config) -> bool {
config.premium.enabled && relay_advertised(config)
}
/// Whether the public feed is enabled AND the relay it reads is advertised.
fn feed_advertised(config: &Config) -> bool {
config.feed.enabled && relay_advertised(config)
}
/// Describe this instance's enabled chains and features.
#[utoipa::path(
get,
+1
View File
@@ -77,6 +77,7 @@ use utoipa::OpenApi;
crate::api::capabilities::MessagingCapability,
crate::api::capabilities::PremiumCapability,
crate::api::capabilities::PremiumPlanInfo,
crate::api::capabilities::FeedCapability,
crate::api::prices::PricesResponse,
crate::api::auth::AuthResponse,
crate::api::auth::UserInfo,
+54
View File
@@ -165,6 +165,7 @@ pub struct Config {
pub registration: RegistrationConfig,
pub messaging: MessagingConfig,
pub premium: PremiumConfig,
pub feed: FeedConfig,
}
#[derive(Clone)]
@@ -602,6 +603,26 @@ pub struct RelayConfig {
pub retention_days: u32,
}
/// Public curated feed (`feed.<domain>`) — an operator knob for what the
/// read-only web feed shows. It reads from the relay, so it requires
/// `RELAY_ENABLED`. All public display config, no secrets.
#[derive(Clone)]
pub struct FeedConfig {
/// Master switch — advertise a curated public feed for this instance.
pub enabled: bool,
/// Include the operator's own posts.
pub show_owner: bool,
/// Include premium members' posts (all general notes on the gated relay).
pub show_premium: bool,
/// The operator's npub (hex or `npub1…`) for owner filtering + display;
/// empty when unset.
pub owner_npub: String,
/// Specific featured npubs to surface (hex or `npub1…`).
pub allowlist_npubs: Vec<String>,
/// Extra relays to also pull the allowlisted authors from.
pub extra_relays: Vec<String>,
}
impl Config {
/// Load configuration from the environment and validate it (fail-closed).
pub fn from_env() -> Result<Self, AppError> {
@@ -805,6 +826,14 @@ impl Config {
currency: env_or("PREMIUM_CURRENCY", "").to_uppercase(),
plans: parse_premium_plans(&env_or("PREMIUM_PLANS", "")),
},
feed: FeedConfig {
enabled: env_bool("FEED_ENABLED", false),
show_owner: env_bool("FEED_SHOW_OWNER", true),
show_premium: env_bool("FEED_SHOW_PREMIUM", true),
owner_npub: env_or("FEED_OWNER_NPUB", "").trim().to_string(),
allowlist_npubs: env_list("FEED_ALLOWLIST_NPUBS"),
extra_relays: env_list("FEED_EXTRA_RELAYS"),
},
};
cfg.validate()?;
@@ -1122,6 +1151,13 @@ impl Config {
// Premium tier reuses the registration PaymentProvider for invoicing, so
// the processor credentials must be present, the relay must be on with the
// premium-post policy, and there must be at least one priced plan.
// The public feed reads from the relay, so it needs the relay enabled.
if self.feed.enabled && !self.messaging.relay.enabled {
return Err(cfg_err(
"FEED_ENABLED needs RELAY_ENABLED=true (the feed reads posts from the relay)",
));
}
if self.premium.enabled {
let p = &self.registration.payment;
if !matches!(p.provider.as_str(), "btcpay") {
@@ -1350,6 +1386,14 @@ mod tests {
currency: String::new(),
plans: Vec::new(),
},
feed: FeedConfig {
enabled: false,
show_owner: true,
show_premium: true,
owner_npub: String::new(),
allowlist_npubs: Vec::new(),
extra_relays: Vec::new(),
},
}
}
@@ -1672,6 +1716,16 @@ mod tests {
assert!(c.validate().is_ok());
}
#[test]
fn feed_requires_relay_enabled() {
// FEED_ENABLED without the relay it reads from is a fail-closed misconfig.
let mut c = valid();
c.feed.enabled = true;
assert!(c.validate().is_err());
c.messaging.relay = valid_relay();
assert!(c.validate().is_ok());
}
/// Fully-wired premium tier (free registration + paid premium) on top of the
/// base valid() config: relay on with the premium-post policy, processor
/// creds present, and priced plans.