From ef6d2804129e540e92e6f5e0d480695a7bd2b9be Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 3 Jul 2026 16:58:22 -0400 Subject: [PATCH] feat(feed): operator-configurable public feed advertised via /capabilities A read-only web feed (feed.) 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. --- .env.example | 12 ++++++++ openapi.json | 62 +++++++++++++++++++++++++++++++++++++++++ src/api/capabilities.rs | 41 +++++++++++++++++++++++++++ src/api/openapi.rs | 1 + src/config.rs | 54 +++++++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+) diff --git a/.env.example b/.env.example index 05d7f63..e774bf4 100644 --- a/.env.example +++ b/.env.example @@ -168,6 +168,18 @@ PREMIUM_CURRENCY=USD # Comma-separated plans, each `id:days:amount` (a built-in tiered discount). PREMIUM_PLANS=quarter:90:5,halfyear:182:9,year:365:15 +# ── Public curated feed (optional; powers a read-only web feed like feed.) ── +# 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 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. diff --git a/openapi.json b/openapi.json index 390a35c..dbfbbcc 100644 --- a/openapi.json +++ b/openapi.json @@ -2100,6 +2100,17 @@ "features": { "$ref": "#/components/schemas/FeatureCapabilities" }, + "feed": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FeedCapability", + "description": "Public curated feed details; present only when `features.feed`." + } + ] + }, "messaging": { "oneOf": [ { @@ -2401,9 +2412,14 @@ "nostr_identity", "nostr_relay", "premium_relay", + "feed", "tips" ], "properties": { + "feed": { + "type": "boolean", + "description": "Public curated Nostr feed for this instance. See `feed`." + }, "grin_relay": { "type": "boolean", "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.`) 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": { "type": "object", "description": "Record that the sender finalized + broadcast the transaction.", diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index 9a10e86..49fd15d 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -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.`) 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, + /// Specific featured npubs to surface. + pub allowlist_npubs: Vec, + /// Extra relays to also pull the allowlisted authors from. + pub extra_relays: Vec, +} + /// 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, + /// Public curated feed details; present only when `features.feed`. + #[serde(skip_serializing_if = "Option::is_none")] + pub feed: Option, } /// 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, diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 985e363..5b2603c 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -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, diff --git a/src/config.rs b/src/config.rs index e1e807c..50c2b19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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.`) — 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, + /// Extra relays to also pull the allowlisted authors from. + pub extra_relays: Vec, +} + impl Config { /// Load configuration from the environment and validate it (fail-closed). pub fn from_env() -> Result { @@ -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.