mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-20 00:38:41 +02:00
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.
317 lines
11 KiB
Rust
317 lines
11 KiB
Rust
//! Premium subscription endpoints — recurring, tiered access to the operator's
|
|
//! Nostr relay for general posting (the `premium-post` write policy). Reuses the
|
|
//! registration PaymentProvider: `POST /premium/invoice` mints a tier invoice,
|
|
//! `POST /premium/activate` verifies settlement (PULL model) and extends the
|
|
//! caller's premium window, `GET /premium/status` reports it. Non-custodial — the
|
|
//! backend only READS invoice status, never holds funds.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
extract::State,
|
|
http::HeaderMap,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
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)]
|
|
pub struct InvoiceReq {
|
|
/// Plan id (see `/capabilities` → `premium.plans`), e.g. `quarter`.
|
|
pub plan: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct InvoiceResp {
|
|
pub invoice_id: String,
|
|
/// Where to pay (a checkout URL or address).
|
|
pub pay_to: String,
|
|
pub plan: String,
|
|
pub amount: String,
|
|
pub currency: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct ActivateReq {
|
|
pub invoice_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct StatusResp {
|
|
/// Whether the caller currently holds active premium.
|
|
pub active: bool,
|
|
/// Premium expiry (RFC3339), or null if never premium / lapsed.
|
|
pub premium_until: Option<String>,
|
|
/// 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.
|
|
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
|
|
if state.cfg().premium.enabled {
|
|
Ok(())
|
|
} else {
|
|
Err(AppError::ValidationError(
|
|
"Premium is not available on this instance.".into(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Mint a premium invoice for a chosen plan, bound to the caller.
|
|
#[utoipa::path(
|
|
security(("bearer_auth" = [])),
|
|
post,
|
|
path = "/premium/invoice",
|
|
request_body = InvoiceReq,
|
|
responses(
|
|
(status = 200, description = "Invoice minted", body = InvoiceResp),
|
|
(status = 400, description = "Premium off or unknown plan"),
|
|
(status = 401, description = "Missing or invalid token")
|
|
),
|
|
tag = "premium"
|
|
)]
|
|
#[instrument(skip(state, headers))]
|
|
pub async fn invoice(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<InvoiceReq>,
|
|
) -> Result<Json<InvoiceResp>, AppError> {
|
|
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
|
ensure_enabled(&state)?;
|
|
|
|
let cfg = state.cfg();
|
|
let plan = cfg
|
|
.premium
|
|
.plans
|
|
.iter()
|
|
.find(|p| p.id == req.plan)
|
|
.ok_or_else(|| AppError::ValidationError("Unknown premium plan.".into()))?;
|
|
|
|
// Cap outstanding unconsumed invoices per user — each mints a real processor
|
|
// invoice, so bound the self-authenticated amplification into the processor.
|
|
const MAX_PENDING: i64 = 8;
|
|
if state.db.count_unconsumed_premium_invoices(user_id).await? >= MAX_PENDING {
|
|
return Err(AppError::ValidationError(
|
|
"Too many pending premium invoices; pay or let them expire before minting another."
|
|
.into(),
|
|
));
|
|
}
|
|
|
|
let provider = state
|
|
.payment
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::Internal("payment provider not configured".into()))?;
|
|
|
|
let pay = &state.cfg().registration.payment;
|
|
let currency = state.cfg().premium.currency.clone();
|
|
let invoice = provider
|
|
.create_invoice(&InvoiceRequest {
|
|
amount: plan.amount.clone(),
|
|
currency: currency.clone(),
|
|
confirmations: pay.confirmations,
|
|
bind: user_id.to_string(),
|
|
expires_minutes: pay.expires_minutes,
|
|
})
|
|
.await?;
|
|
|
|
state
|
|
.db
|
|
.insert_premium_invoice(
|
|
&invoice.id,
|
|
user_id,
|
|
provider.kind(),
|
|
&plan.id,
|
|
plan.days,
|
|
&plan.amount,
|
|
¤cy,
|
|
)
|
|
.await?;
|
|
|
|
Ok(Json(InvoiceResp {
|
|
invoice_id: invoice.id,
|
|
pay_to: invoice.pay_to,
|
|
plan: plan.id.clone(),
|
|
amount: plan.amount.clone(),
|
|
currency,
|
|
}))
|
|
}
|
|
|
|
/// Verify a settled premium invoice and extend the caller's premium window.
|
|
#[utoipa::path(
|
|
security(("bearer_auth" = [])),
|
|
post,
|
|
path = "/premium/activate",
|
|
request_body = ActivateReq,
|
|
responses(
|
|
(status = 200, description = "Premium extended", body = StatusResp),
|
|
(status = 400, description = "Unknown, unpaid, or already-used invoice"),
|
|
(status = 401, description = "Missing or invalid token")
|
|
),
|
|
tag = "premium"
|
|
)]
|
|
#[instrument(skip(state, headers))]
|
|
pub async fn activate(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ActivateReq>,
|
|
) -> Result<Json<StatusResp>, AppError> {
|
|
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
|
ensure_enabled(&state)?;
|
|
|
|
let provider = state
|
|
.payment
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::Internal("payment provider not configured".into()))?;
|
|
|
|
let id = req.invoice_id.trim();
|
|
let uid = user_id.to_string();
|
|
|
|
// Binding precheck (local DB): must exist, be bound to THIS user, and unspent.
|
|
// A missing row and a row bound to a different user collapse to one error.
|
|
let row = match state.db.get_premium_invoice(id).await? {
|
|
Some(r) if r.user_id == user_id => r,
|
|
_ => {
|
|
return Err(AppError::ValidationError(
|
|
"Unknown or invalid premium invoice.".into(),
|
|
))
|
|
}
|
|
};
|
|
if row.consumed_at.is_some() {
|
|
return Err(AppError::ValidationError(
|
|
"This premium invoice has already been used.".into(),
|
|
));
|
|
}
|
|
|
|
// Source of truth: the processor. Only Settled grants (paid in full per policy).
|
|
let inv = provider.get_invoice(id).await?;
|
|
if inv.status != InvoiceStatus::Settled {
|
|
return Err(AppError::ValidationError(
|
|
"Payment not yet confirmed. Complete the payment and retry.".into(),
|
|
));
|
|
}
|
|
// Defence-in-depth: the settled invoice's metadata bind must match this user.
|
|
if let Some(ref bound) = inv.bind {
|
|
if bound != &uid {
|
|
return Err(AppError::ValidationError(
|
|
"Unknown or invalid premium invoice.".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Atomic single-use consume + extend (one transaction — a mid-flight failure
|
|
// rolls back so the paid invoice stays redeemable). None = already used/raced.
|
|
let until = match state
|
|
.db
|
|
.activate_premium(id, user_id, row.period_days)
|
|
.await?
|
|
{
|
|
Some(u) => u,
|
|
None => {
|
|
return Err(AppError::ValidationError(
|
|
"This premium invoice has already been used.".into(),
|
|
))
|
|
}
|
|
};
|
|
info!(
|
|
days = row.period_days,
|
|
"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,
|
|
}))
|
|
}
|
|
|
|
/// The caller's current premium status.
|
|
#[utoipa::path(
|
|
security(("bearer_auth" = [])),
|
|
get,
|
|
path = "/premium/status",
|
|
responses(
|
|
(status = 200, description = "Premium status", body = StatusResp),
|
|
(status = 401, description = "Missing or invalid token")
|
|
),
|
|
tag = "premium"
|
|
)]
|
|
#[instrument(skip(state, headers))]
|
|
pub async fn status(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<StatusResp>, AppError> {
|
|
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,
|
|
}))
|
|
}
|
|
|
|
pub fn routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/premium/invoice", post(invoice))
|
|
.route("/premium/activate", post(activate))
|
|
.route("/premium/status", get(status))
|
|
}
|