mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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.<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 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.
|
||||
|
||||
@@ -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.<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": {
|
||||
"type": "object",
|
||||
"description": "Record that the sender finalized + broadcast the transaction.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user