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
+12
View File
@@ -168,6 +168,18 @@ PREMIUM_CURRENCY=USD
# Comma-separated plans, each `id:days:amount` (a built-in tiered discount). # Comma-separated plans, each `id:days:amount` (a built-in tiered discount).
PREMIUM_PLANS=quarter:90:5,halfyear:182:9,year:365:15 PREMIUM_PLANS=quarter:90:5,halfyear:182:9,year:365:15
# ── Public curated feed (optional; powers a read-only web feed like feed.<domain>) ──
# Reads FROM the relay, so it needs RELAY_ENABLED. All public display config —
# what the feed shows. Off by default; advertised via /capabilities.feed when on.
FEED_ENABLED=false
FEED_SHOW_OWNER=true
FEED_SHOW_PREMIUM=true
# Operator npub (hex or npub1…) for "owner" posts + display; empty if none.
FEED_OWNER_NPUB=
# Comma-separated featured npubs, and extra relays to also pull those authors from.
FEED_ALLOWLIST_NPUBS=
FEED_EXTRA_RELAYS=
# ── Admin surface (see docs; default posture is loopback/Tor) ────────────── # ── Admin surface (see docs; default posture is loopback/Tor) ──────────────
# Admin auth is "Sign in with Smirk": only admin PUBLIC keys are stored here, # Admin auth is "Sign in with Smirk": only admin PUBLIC keys are stored here,
# never a seed or private key. The allowlist is mutated CLI/loopback-only in v1. # never a seed or private key. The allowlist is mutated CLI/loopback-only in v1.
+62
View File
@@ -2100,6 +2100,17 @@
"features": { "features": {
"$ref": "#/components/schemas/FeatureCapabilities" "$ref": "#/components/schemas/FeatureCapabilities"
}, },
"feed": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/FeedCapability",
"description": "Public curated feed details; present only when `features.feed`."
}
]
},
"messaging": { "messaging": {
"oneOf": [ "oneOf": [
{ {
@@ -2401,9 +2412,14 @@
"nostr_identity", "nostr_identity",
"nostr_relay", "nostr_relay",
"premium_relay", "premium_relay",
"feed",
"tips" "tips"
], ],
"properties": { "properties": {
"feed": {
"type": "boolean",
"description": "Public curated Nostr feed for this instance. See `feed`."
},
"grin_relay": { "grin_relay": {
"type": "boolean", "type": "boolean",
"description": "Grin async slatepack relay mailbox." "description": "Grin async slatepack relay mailbox."
@@ -2469,6 +2485,52 @@
} }
} }
}, },
"FeedCapability": {
"type": "object",
"description": "Public curated feed details (present only when `features.feed`). A read-only\nweb feed (e.g. `feed.<domain>`) reads from the relay and shows posts per the\noperator's curation knobs.",
"required": [
"relay_url",
"show_owner",
"show_premium",
"allowlist_npubs",
"extra_relays"
],
"properties": {
"allowlist_npubs": {
"type": "array",
"items": {
"type": "string"
},
"description": "Specific featured npubs to surface."
},
"extra_relays": {
"type": "array",
"items": {
"type": "string"
},
"description": "Extra relays to also pull the allowlisted authors from."
},
"owner_npub": {
"type": [
"string",
"null"
],
"description": "The operator's npub for owner filtering + display; `null` when unset."
},
"relay_url": {
"type": "string",
"description": "The relay the feed reads from (mirrors `messaging.relay_url`)."
},
"show_owner": {
"type": "boolean",
"description": "Include the operator's own posts."
},
"show_premium": {
"type": "boolean",
"description": "Include premium members' posts (all general notes on the gated relay)."
}
}
},
"FinalizeRelayRequest": { "FinalizeRelayRequest": {
"type": "object", "type": "object",
"description": "Record that the sender finalized + broadcast the transaction.", "description": "Record that the sender finalized + broadcast the transaction.",
+41
View File
@@ -49,6 +49,8 @@ pub struct FeatureCapabilities {
pub nostr_relay: bool, pub nostr_relay: bool,
/// Paid premium tier for general Nostr posting to the relay. See `premium`. /// Paid premium tier for general Nostr posting to the relay. See `premium`.
pub premium_relay: bool, pub premium_relay: bool,
/// Public curated Nostr feed for this instance. See `feed`.
pub feed: bool,
/// Tipping (parked). /// Tipping (parked).
pub tips: bool, pub tips: bool,
} }
@@ -88,6 +90,25 @@ pub struct PremiumCapability {
pub relay_url: String, 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 /// 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 /// 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 /// 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`. /// Premium tier details; present only when `features.premium_relay`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub premium: Option<PremiumCapability>, 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 /// 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). // presence downgrade — never advertise a relay clients can't reach).
nostr_relay: relay_advertised(config), nostr_relay: relay_advertised(config),
premium_relay: premium_advertised(config), premium_relay: premium_advertised(config),
feed: feed_advertised(config),
tips: config.features.tips, tips: config.features.tips,
}, },
restore: RestoreCapability { restore: RestoreCapability {
@@ -252,6 +277,17 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
.collect(), .collect(),
relay_url: config.messaging.relay.advertised_url.clone(), 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) 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. /// Describe this instance's enabled chains and features.
#[utoipa::path( #[utoipa::path(
get, get,
+1
View File
@@ -77,6 +77,7 @@ use utoipa::OpenApi;
crate::api::capabilities::MessagingCapability, crate::api::capabilities::MessagingCapability,
crate::api::capabilities::PremiumCapability, crate::api::capabilities::PremiumCapability,
crate::api::capabilities::PremiumPlanInfo, crate::api::capabilities::PremiumPlanInfo,
crate::api::capabilities::FeedCapability,
crate::api::prices::PricesResponse, crate::api::prices::PricesResponse,
crate::api::auth::AuthResponse, crate::api::auth::AuthResponse,
crate::api::auth::UserInfo, crate::api::auth::UserInfo,
+54
View File
@@ -165,6 +165,7 @@ pub struct Config {
pub registration: RegistrationConfig, pub registration: RegistrationConfig,
pub messaging: MessagingConfig, pub messaging: MessagingConfig,
pub premium: PremiumConfig, pub premium: PremiumConfig,
pub feed: FeedConfig,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -602,6 +603,26 @@ pub struct RelayConfig {
pub retention_days: u32, 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 { impl Config {
/// Load configuration from the environment and validate it (fail-closed). /// Load configuration from the environment and validate it (fail-closed).
pub fn from_env() -> Result<Self, AppError> { pub fn from_env() -> Result<Self, AppError> {
@@ -805,6 +826,14 @@ impl Config {
currency: env_or("PREMIUM_CURRENCY", "").to_uppercase(), currency: env_or("PREMIUM_CURRENCY", "").to_uppercase(),
plans: parse_premium_plans(&env_or("PREMIUM_PLANS", "")), 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()?; cfg.validate()?;
@@ -1122,6 +1151,13 @@ impl Config {
// Premium tier reuses the registration PaymentProvider for invoicing, so // Premium tier reuses the registration PaymentProvider for invoicing, so
// the processor credentials must be present, the relay must be on with the // 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. // 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 { if self.premium.enabled {
let p = &self.registration.payment; let p = &self.registration.payment;
if !matches!(p.provider.as_str(), "btcpay") { if !matches!(p.provider.as_str(), "btcpay") {
@@ -1350,6 +1386,14 @@ mod tests {
currency: String::new(), currency: String::new(),
plans: Vec::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()); 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 /// Fully-wired premium tier (free registration + paid premium) on top of the
/// base valid() config: relay on with the premium-post policy, processor /// base valid() config: relay on with the premium-post policy, processor
/// creds present, and priced plans. /// creds present, and priced plans.