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).
|
# 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.
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user