From ae118edf86b855cadd46d1f330a78d9586aefa05 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 31 Jul 2026 17:17:58 -0400 Subject: [PATCH] premium: publish the relay posting decision, not its inputs GET /premium/status gains write_allowlisted + can_post_general. The wallet was re-deriving posting rights from write_policy + premium, which cannot see RELAY_WRITE_ALLOWLIST_NPUBS. An allowlisted operator was shown "posting needs a premium subscription" with the composer hidden, while the relay's own admission check would have accepted the event. Compute the answer server-side from the same RelayProvider the admission service consults so the two cannot drift. --- openapi.json | 12 +++++++++- src/api/premium.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/openapi.json b/openapi.json index a949f55..f799711 100644 --- a/openapi.json +++ b/openapi.json @@ -4991,19 +4991,29 @@ "StatusResp": { "type": "object", "required": [ - "active" + "active", + "write_allowlisted", + "can_post_general" ], "properties": { "active": { "type": "boolean", "description": "Whether the caller currently holds active premium." }, + "can_post_general": { + "type": "boolean", + "description": "Whether the caller may publish general (non-wallet) events to this\ninstance's relay RIGHT NOW.\n\nThe authority on this is [`crate::infra::relay::policy::decide`], which\nruns server-side at admission. Clients previously re-derived it from\n`write_policy` + premium alone and so had no way to know about the\noperator write-allowlist: an allowlisted operator was told \"premium\nrequired\" and the composer was hidden, even though the relay would have\naccepted the event. Publish the decision instead of the inputs." + }, "premium_until": { "type": [ "string", "null" ], "description": "Premium expiry (RFC3339), or null if never premium / lapsed." + }, + "write_allowlisted": { + "type": "boolean", + "description": "Whether the caller's linked npub is on the operator write-allowlist\n(`RELAY_WRITE_ALLOWLIST_NPUBS`), which permits publishing ANY kind\nregardless of policy or premium." } } }, diff --git a/src/api/premium.rs b/src/api/premium.rs index 2e0f0d3..0685664 100644 --- a/src/api/premium.rs +++ b/src/api/premium.rs @@ -19,6 +19,7 @@ use tracing::{info, instrument}; use crate::api::middleware::extract_user_id_from_token; use crate::error::AppError; use crate::infra::payment::{InvoiceRequest, InvoiceStatus}; +use crate::infra::relay::WritePolicy; use crate::AppState; #[derive(Debug, Deserialize, utoipa::ToSchema)] @@ -48,6 +49,50 @@ pub struct StatusResp { pub active: bool, /// Premium expiry (RFC3339), or null if never premium / lapsed. pub premium_until: Option, + /// Whether the caller's linked npub is on the operator write-allowlist + /// (`RELAY_WRITE_ALLOWLIST_NPUBS`), which permits publishing ANY kind + /// regardless of policy or premium. + pub write_allowlisted: bool, + /// Whether the caller may publish general (non-wallet) events to this + /// instance's relay RIGHT NOW. + /// + /// The authority on this is [`crate::infra::relay::policy::decide`], which + /// runs server-side at admission. Clients previously re-derived it from + /// `write_policy` + premium alone and so had no way to know about the + /// operator write-allowlist: an allowlisted operator was told "premium + /// required" and the composer was hidden, even though the relay would have + /// accepted the event. Publish the decision instead of the inputs. + pub can_post_general: bool, +} + +/// Whether `user` may publish a general (non-wallet) event to this instance's +/// relay, and whether they are write-allowlisted. +/// +/// Deliberately mirrors [`crate::infra::relay::policy::decide`] for the +/// general-event case, using the SAME `RelayProvider` the admission service +/// consults, so the answer the client is given cannot drift from the answer the +/// relay will actually give. Returns `(write_allowlisted, can_post_general)`. +fn general_posting_rights(state: &AppState, nostr_pubkey: Option<&str>) -> (bool, bool) { + let Some(relay) = state.relay.as_ref() else { + // No first-party relay on this instance, so there is nothing to post to. + return (false, false); + }; + let allowlisted = nostr_pubkey + .map(|pk| relay.is_write_allowlisted(&pk.to_ascii_lowercase())) + .unwrap_or(false); + if allowlisted { + return (true, true); + } + // An unlinked account has no npub, so it cannot author anything. + let registered = nostr_pubkey.is_some(); + let can = match relay.write_policy() { + WritePolicy::Open => true, + // A general event under premium-post needs premium, full stop; the + // free-for-registered carve-out covers wallet kinds only. + WritePolicy::PremiumPost => false, + WritePolicy::AuthorAllowlist | WritePolicy::InboxOutbox => registered, + }; + (false, can) } /// Reject when the premium tier is not enabled on this instance. @@ -221,9 +266,15 @@ pub async fn activate( "premium invoice settled + activated; window extended" ); + // Just activated, so general posting is permitted regardless of policy; still + // report the allowlist bit truthfully. + let npub = state.db.get_user_by_id(user_id).await?.and_then(|u| u.nostr_pubkey); + let (write_allowlisted, _) = general_posting_rights(&state, npub.as_deref()); Ok(Json(StatusResp { active: true, premium_until: Some(until.to_rfc3339()), + write_allowlisted, + can_post_general: true, })) } @@ -246,9 +297,14 @@ pub async fn status( let user_id = extract_user_id_from_token(&state, &headers).await?; let until = state.db.get_premium_until(user_id).await?; let active = until.map(|u| u > chrono::Utc::now()).unwrap_or(false); + let npub = state.db.get_user_by_id(user_id).await?.and_then(|u| u.nostr_pubkey); + let (write_allowlisted, can_general) = general_posting_rights(&state, npub.as_deref()); Ok(Json(StatusResp { active, premium_until: until.map(|u| u.to_rfc3339()), + write_allowlisted, + // Premium is one way in; the operator write-allowlist is the other. + can_post_general: active || can_general, })) }