From eb98e4d625b4cb6388c9b04b6248a2b4819060e5 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 2 Jul 2026 17:56:44 -0400 Subject: [PATCH] feat(premium): recurring tiered relay-posting subscription (premium-post) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paid premium tier gating GENERAL Nostr posting to the operator's relay, while wallet-functional events (encrypted DMs, tips, swap coordination) stay free for registered users. Registration itself stays free (PoW-gated). Off by default. - Admission: a new `premium-post` write policy gates on kind x membership — premium author -> any kind; registered + WALLET_KINDS (1059 gift-wrap, 10050 DM-relay-list) -> free; registered + general -> premium required; external -> gift-wrap DM inbox only. nauthz resolves is_premium_npub (only under premium-post), fail-closed. - Payment: reuses the PaymentProvider seam (PULL model, non-custodial — only reads invoice status). POST /premium/invoice {plan} mints a tier invoice bound to the JWT user_id; POST /premium/activate verifies Settled + consumes single-use AND extends premium_until in ONE transaction (a mid-flight failure keeps the paid invoice redeemable); GET /premium/status reads it. Per-user pending-invoice cap. - Tiered plans (PREMIUM_PLANS id:days:amount) = a built-in discount feature; advertised via /capabilities (premium_relay + premium{currency,plans,relay_url}). - Config fail-closes: PREMIUM_ENABLED needs relay on + premium-post + processor creds + >=1 positive-decimal plan + unique ids + days<=3660, and the 0-conf / expiry processor floors apply on the premium path too (its common case is require_payment=false). - Tests: 15 policy unit (incl. 5 premium-post) + config fail-closed matrix + plan parser + L1 integration (activate single-use/stacking/cross-user, is_premium by expiry, feature gate). OpenAPI + migration updated. Adversarially reviewed (correctness / security-payment / operational-config); the 0-conf-on-premium-path double-spend footgun, non-transactional activate, and unbounded plan days were caught and fixed before commit. --- migrations/20260702000001_premium.sql | 26 +++ openapi.json | 248 ++++++++++++++++++++++++ src/api/capabilities.rs | 46 +++++ src/api/mod.rs | 1 + src/api/openapi.rs | 12 +- src/api/premium.rs | 260 ++++++++++++++++++++++++++ src/config.rs | 260 +++++++++++++++++++++++++- src/infra/db/mod.rs | 2 + src/infra/db/premium_invoices.rs | 156 ++++++++++++++++ src/infra/db/users.rs | 55 ++++++ src/infra/payment/mod.rs | 4 +- src/infra/relay/nauthz.rs | 11 +- src/infra/relay/policy.rs | 131 ++++++++++--- src/lib.rs | 1 + tests/premium.rs | 153 +++++++++++++++ 15 files changed, 1339 insertions(+), 27 deletions(-) create mode 100644 migrations/20260702000001_premium.sql create mode 100644 src/api/premium.rs create mode 100644 src/infra/db/premium_invoices.rs create mode 100644 tests/premium.rs diff --git a/migrations/20260702000001_premium.sql b/migrations/20260702000001_premium.sql new file mode 100644 index 0000000..ba75bd6 --- /dev/null +++ b/migrations/20260702000001_premium.sql @@ -0,0 +1,26 @@ +-- Premium tier: recurring, tiered access to the operator's Nostr relay for +-- general posting (the `premium-post` write policy). Wallet-functional events +-- stay free; premium unlocks general Nostr publishing. +-- +-- `premium_until` is the subscription expiry (NULL = never premium). No index: the +-- hot check (is_premium_npub) is served by the existing UNIQUE index on +-- users.nostr_pubkey, and the `premium_until > NOW()` predicate is a cheap per-row +-- filter — a standalone index would only add write cost to every users UPDATE. +ALTER TABLE users ADD COLUMN premium_until TIMESTAMPTZ; + +-- Mirrors payment_invoices, but binds on the authenticated user_id (a logged-in +-- premium purchase) and carries the plan's period. Single-use on redemption, +-- same atomic consume pattern. Only public data — never funds or keys. +CREATE TABLE premium_invoices ( + invoice_id TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + plan_id TEXT NOT NULL, + period_days INTEGER NOT NULL, + amount TEXT NOT NULL, + currency TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + consumed_at TIMESTAMPTZ +); +CREATE INDEX premium_invoices_unconsumed_idx + ON premium_invoices (user_id) WHERE consumed_at IS NULL; diff --git a/openapi.json b/openapi.json index 33be2f5..20805ab 100644 --- a/openapi.json +++ b/openapi.json @@ -513,6 +513,119 @@ ] } }, + "/premium/activate": { + "post": { + "tags": [ + "premium" + ], + "summary": "Verify a settled premium invoice and extend the caller's premium window.", + "operationId": "activate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateReq" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Premium extended", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResp" + } + } + } + }, + "400": { + "description": "Unknown, unpaid, or already-used invoice" + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/premium/invoice": { + "post": { + "tags": [ + "premium" + ], + "summary": "Mint a premium invoice for a chosen plan, bound to the caller.", + "operationId": "invoice", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoiceReq" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Invoice minted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvoiceResp" + } + } + } + }, + "400": { + "description": "Premium off or unknown plan" + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/premium/status": { + "get": { + "tags": [ + "premium" + ], + "summary": "The caller's current premium status.", + "operationId": "status", + "responses": { + "200": { + "description": "Premium status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResp" + } + } + } + }, + "401": { + "description": "Missing or invalid token" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/prices": { "get": { "tags": [ @@ -1770,6 +1883,17 @@ }, "components": { "schemas": { + "ActivateReq": { + "type": "object", + "required": [ + "invoice_id" + ], + "properties": { + "invoice_id": { + "type": "string" + } + } + }, "AddressRequest": { "type": "object", "description": "An address query for a UTXO asset.", @@ -1987,6 +2111,17 @@ } ] }, + "premium": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PremiumCapability", + "description": "Premium tier details; present only when `features.premium_relay`." + } + ] + }, "registration": { "$ref": "#/components/schemas/RegistrationCapability", "description": "Registration gates for a new wallet on this instance." @@ -2265,6 +2400,7 @@ "prices", "nostr_identity", "nostr_relay", + "premium_relay", "tips" ], "properties": { @@ -2280,6 +2416,10 @@ "type": "boolean", "description": "First-party Nostr relay (encrypted DM inbox). See `messaging` for details." }, + "premium_relay": { + "type": "boolean", + "description": "Paid premium tier for general Nostr posting to the relay. See `premium`." + }, "prices": { "type": "boolean", "description": "Fiat price feed." @@ -2603,6 +2743,46 @@ } } }, + "InvoiceReq": { + "type": "object", + "required": [ + "plan" + ], + "properties": { + "plan": { + "type": "string", + "description": "Plan id (see `/capabilities` → `premium.plans`), e.g. `quarter`." + } + } + }, + "InvoiceResp": { + "type": "object", + "required": [ + "invoice_id", + "pay_to", + "plan", + "amount", + "currency" + ], + "properties": { + "amount": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "invoice_id": { + "type": "string" + }, + "pay_to": { + "type": "string", + "description": "Where to pay (a checkout URL or address)." + }, + "plan": { + "type": "string" + } + } + }, "LogoutRequest": { "type": "object", "required": [ @@ -2912,6 +3092,51 @@ } } }, + "PremiumCapability": { + "type": "object", + "description": "Premium tier details (present only when `features.premium_relay`). The wallet\nrenders the plan tiers (discount visible) and gates general-Nostr posting to\nthe Smirk relay on the user's premium status; wallet events stay free.", + "required": [ + "currency", + "plans", + "relay_url" + ], + "properties": { + "currency": { + "type": "string" + }, + "plans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PremiumPlanInfo" + } + }, + "relay_url": { + "type": "string", + "description": "The relay premium posting targets (mirrors `messaging.relay_url`)." + } + } + }, + "PremiumPlanInfo": { + "type": "object", + "description": "A single premium plan (discount tier): `days` of relay-posting access for\n`amount` in [`PremiumCapability::currency`].", + "required": [ + "id", + "days", + "amount" + ], + "properties": { + "amount": { + "type": "string" + }, + "days": { + "type": "integer", + "format": "int32" + }, + "id": { + "type": "string" + } + } + }, "PricesResponse": { "type": "object", "required": [ @@ -3347,6 +3572,25 @@ } } }, + "StatusResp": { + "type": "object", + "required": [ + "active" + ], + "properties": { + "active": { + "type": "boolean", + "description": "Whether the caller currently holds active premium." + }, + "premium_until": { + "type": [ + "string", + "null" + ], + "description": "Premium expiry (RFC3339), or null if never premium / lapsed." + } + } + }, "SubmitRequest": { "type": "object", "description": "Submit a finalized, signed transaction.", @@ -3799,6 +4043,10 @@ { "name": "grin_relay", "description": "Grin slatepack relay: async non-custodial mailbox for interactive Grin transfers (feature-flagged)." + }, + { + "name": "premium", + "description": "Premium subscription: mint a tier invoice, activate on settlement, read status (feature-flagged)." } ] } diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index 95e94a5..f2c920b 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -47,6 +47,8 @@ pub struct FeatureCapabilities { pub nostr_identity: bool, /// First-party Nostr relay (encrypted DM inbox). See `messaging` for details. pub nostr_relay: bool, + /// Paid premium tier for general Nostr posting to the relay. See `premium`. + pub premium_relay: bool, /// Tipping (parked). pub tips: bool, } @@ -66,6 +68,26 @@ pub struct MessagingCapability { pub supported_nips: Vec, } +/// A single premium plan (discount tier): `days` of relay-posting access for +/// `amount` in [`PremiumCapability::currency`]. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PremiumPlanInfo { + pub id: String, + pub days: i32, + pub amount: String, +} + +/// Premium tier details (present only when `features.premium_relay`). The wallet +/// renders the plan tiers (discount visible) and gates general-Nostr posting to +/// the Smirk relay on the user's premium status; wallet events stay free. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PremiumCapability { + pub currency: String, + pub plans: Vec, + /// The relay premium posting targets (mirrors `messaging.relay_url`). + pub relay_url: 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 @@ -117,6 +139,9 @@ pub struct CapabilitiesResponse { /// First-party Nostr relay details; present only when `features.nostr_relay`. #[serde(skip_serializing_if = "Option::is_none")] pub messaging: Option, + /// Premium tier details; present only when `features.premium_relay`. + #[serde(skip_serializing_if = "Option::is_none")] + pub premium: Option, } /// Whether an enabled chain can actually be served (its infra secret/URL is @@ -176,6 +201,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { // Relay advertised only when enabled AND a URL is configured (config // presence downgrade — never advertise a relay clients can't reach). nostr_relay: relay_advertised(config), + premium_relay: premium_advertised(config), tips: config.features.tips, }, restore: RestoreCapability { @@ -212,6 +238,20 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { supported_nips: crate::infra::relay::SUPPORTED_NIPS.to_vec(), } }), + premium: premium_advertised(config).then(|| PremiumCapability { + currency: config.premium.currency.clone(), + plans: config + .premium + .plans + .iter() + .map(|p| PremiumPlanInfo { + id: p.id.clone(), + days: p.days, + amount: p.amount.clone(), + }) + .collect(), + relay_url: config.messaging.relay.advertised_url.clone(), + }), } } @@ -222,6 +262,12 @@ fn relay_advertised(config: &Config) -> bool { config.messaging.relay.enabled && !config.messaging.relay.advertised_url.trim().is_empty() } +/// Whether the premium tier is enabled AND the relay it gates is advertised +/// (config validation already couples premium to relay + the premium-post policy). +fn premium_advertised(config: &Config) -> bool { + config.premium.enabled && relay_advertised(config) +} + /// Describe this instance's enabled chains and features. #[utoipa::path( get, diff --git a/src/api/mod.rs b/src/api/mod.rs index 28ddd01..51922d3 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,6 +9,7 @@ pub mod landing; pub mod middleware; pub mod nip05; pub mod openapi; +pub mod premium; pub mod prices; pub mod users; pub mod wallet; diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 78f0380..985e363 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -62,6 +62,9 @@ use utoipa::OpenApi; crate::api::wallet::grin_relay::cancel, crate::api::wallet::heights::heights, crate::api::nip05::well_known_nostr, + crate::api::premium::invoice, + crate::api::premium::activate, + crate::api::premium::status, ), components(schemas( crate::api::health::HealthResponse, @@ -72,6 +75,8 @@ use utoipa::OpenApi; crate::api::capabilities::RestoreCapability, crate::api::capabilities::RegistrationCapability, crate::api::capabilities::MessagingCapability, + crate::api::capabilities::PremiumCapability, + crate::api::capabilities::PremiumPlanInfo, crate::api::prices::PricesResponse, crate::api::auth::AuthResponse, crate::api::auth::UserInfo, @@ -143,6 +148,10 @@ use utoipa::OpenApi; crate::api::wallet::grin_relay::PendingRelaysResponse, crate::api::wallet::heights::HeightsResponse, crate::api::nip05::WellKnownResponse, + crate::api::premium::InvoiceReq, + crate::api::premium::InvoiceResp, + crate::api::premium::ActivateReq, + crate::api::premium::StatusResp, )), tags( (name = "system", description = "Service health and metadata."), @@ -151,7 +160,8 @@ use utoipa::OpenApi; (name = "btc_ltc", description = "Bitcoin/Litecoin chain access via Electrum (balance, UTXOs, history, tip, fee, broadcast)."), (name = "xmr_wow", description = "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations)."), (name = "grin", description = "Grin view-only chain access (rewind_hash scan, tip height, broadcast)."), - (name = "grin_relay", description = "Grin slatepack relay: async non-custodial mailbox for interactive Grin transfers (feature-flagged).") + (name = "grin_relay", description = "Grin slatepack relay: async non-custodial mailbox for interactive Grin transfers (feature-flagged)."), + (name = "premium", description = "Premium subscription: mint a tier invoice, activate on settlement, read status (feature-flagged).") ) )] pub struct ApiDoc; diff --git a/src/api/premium.rs b/src/api/premium.rs new file mode 100644 index 0000000..9679d07 --- /dev/null +++ b/src/api/premium.rs @@ -0,0 +1,260 @@ +//! 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::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, +} + +/// Reject when the premium tier is not enabled on this instance. +fn ensure_enabled(state: &AppState) -> Result<(), AppError> { + if state.config.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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + ensure_enabled(&state)?; + + let plan = state + .config + .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.config.registration.payment; + let currency = state.config.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>, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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" + ); + + Ok(Json(StatusResp { + active: true, + premium_until: Some(until.to_rfc3339()), + })) +} + +/// 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>, + headers: HeaderMap, +) -> Result, 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); + Ok(Json(StatusResp { + active, + premium_until: until.map(|u| u.to_rfc3339()), + })) +} + +pub fn routes() -> Router> { + Router::new() + .route("/premium/invoice", post(invoice)) + .route("/premium/activate", post(activate)) + .route("/premium/status", get(status)) +} diff --git a/src/config.rs b/src/config.rs index 67cf268..cb3b716 100644 --- a/src/config.rs +++ b/src/config.rs @@ -151,6 +151,7 @@ pub struct Config { pub restore: RestoreConfig, pub registration: RegistrationConfig, pub messaging: MessagingConfig, + pub premium: PremiumConfig, } #[derive(Clone)] @@ -507,6 +508,44 @@ pub struct PaymentConfig { pub expires_minutes: u32, } +/// A single premium purchase option (a discount tier): `days` of relay-posting +/// access for `amount` in the shared [`PremiumConfig::currency`]. +#[derive(Debug, Clone)] +pub struct PremiumPlan { + pub id: String, + pub days: i32, + pub amount: String, +} + +/// Premium subscription tier: recurring, tiered access to the operator's Nostr +/// relay for general posting (the `premium-post` write policy). Reuses the +/// registration PaymentProvider for invoicing. Off by default. +#[derive(Clone)] +pub struct PremiumConfig { + pub enabled: bool, + pub currency: String, + pub plans: Vec, +} + +/// Parse `PREMIUM_PLANS` = comma-separated `id:days:amount` entries (e.g. +/// `quarter:90:5,year:365:15`). Malformed entries are dropped; `validate()` +/// rejects an empty result when premium is enabled. +fn parse_premium_plans(s: &str) -> Vec { + s.split(',') + .filter_map(|entry| { + let mut parts = entry.trim().splitn(3, ':'); + let id = parts.next()?.trim(); + let days = parts.next()?.trim().parse::().ok()?; + let amount = parts.next()?.trim(); + (!id.is_empty() && !amount.is_empty() && days > 0).then(|| PremiumPlan { + id: id.to_string(), + days, + amount: amount.to_string(), + }) + }) + .collect() +} + /// Messaging plane (identity + encrypted delivery). Today it holds the optional /// first-party Nostr relay; the seam leaves room for other messaging backends. #[derive(Clone)] @@ -748,6 +787,11 @@ impl Config { retention_days: env_parse("RELAY_RETENTION_DAYS", 30u32)?, }, }, + premium: PremiumConfig { + enabled: env_bool("PREMIUM_ENABLED", false), + currency: env_or("PREMIUM_CURRENCY", "").to_uppercase(), + plans: parse_premium_plans(&env_or("PREMIUM_PLANS", "")), + }, }; cfg.validate()?; @@ -1009,10 +1053,10 @@ impl Config { } if !matches!( r.write_policy.as_str(), - "inbox-outbox" | "author-allowlist" | "open" + "inbox-outbox" | "author-allowlist" | "open" | "premium-post" ) { return Err(cfg_err(format!( - "RELAY_WRITE_POLICY {:?} is not supported; use inbox-outbox|author-allowlist|open", + "RELAY_WRITE_POLICY {:?} is not supported; use inbox-outbox|author-allowlist|open|premium-post", r.write_policy ))); } @@ -1050,6 +1094,89 @@ 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. + if self.premium.enabled { + let p = &self.registration.payment; + if !matches!(p.provider.as_str(), "btcpay") { + return Err(cfg_err(format!( + "PREMIUM_ENABLED needs a supported PAYMENT_PROVIDER (btcpay); got {:?}", + p.provider + ))); + } + if p.provider_url.trim().is_empty() { + return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_PROVIDER_URL")); + } + if prod && !p.provider_url.starts_with("https://") { + return Err(cfg_err( + "PAYMENT_PROVIDER_URL must be https:// in production", + )); + } + if p.store_id.trim().is_empty() { + return Err(cfg_err("PREMIUM_ENABLED needs PAYMENT_STORE_ID")); + } + if p.api_key.len() < 8 { + return Err(cfg_err( + "PREMIUM_ENABLED needs PAYMENT_API_KEY (>= 8 bytes)", + )); + } + // The same processor-safety floors the pay-to-register path enforces — + // premium's common case is require_payment=false, which skips that + // block. A 0-conf invoice settles on first mempool sighting and can + // then be double-spent AWAY after the premium grant; a 0/oversize + // expiry is a malformed invoice window. + if p.confirmations == 0 { + return Err(cfg_err( + "PAYMENT_CONFIRMATIONS must be >= 1 when PREMIUM_ENABLED is on \ + (0-conf lets a settled premium payment be double-spent after activation)", + )); + } + if p.expires_minutes == 0 || p.expires_minutes > 10080 { + return Err(cfg_err( + "PAYMENT_EXPIRES_MINUTES must be between 1 and 10080 (7 days)", + )); + } + if self.premium.currency.trim().is_empty() { + return Err(cfg_err("PREMIUM_ENABLED needs PREMIUM_CURRENCY")); + } + if !self.messaging.relay.enabled { + return Err(cfg_err("PREMIUM_ENABLED needs RELAY_ENABLED=true")); + } + if self.messaging.relay.write_policy != "premium-post" { + return Err(cfg_err( + "PREMIUM_ENABLED needs RELAY_WRITE_POLICY=premium-post", + )); + } + if self.premium.plans.is_empty() { + return Err(cfg_err( + "PREMIUM_ENABLED needs at least one PREMIUM_PLANS entry (id:days:amount)", + )); + } + let mut seen = std::collections::HashSet::new(); + for plan in &self.premium.plans { + if !seen.insert(plan.id.as_str()) { + return Err(cfg_err(format!("duplicate PREMIUM_PLANS id {:?}", plan.id))); + } + // Same positive-decimal rule as PAYMENT_AMOUNT (rejects NaN/inf/1e5). + if !is_positive_decimal(&plan.amount) { + return Err(cfg_err(format!( + "PREMIUM_PLANS[{}] amount {:?} must be a positive decimal", + plan.id, plan.amount + ))); + } + // Cap the period: a plan longer than ~10 years is a config error, + // and a huge value would overflow the premium_until timestamp math + // (make_interval) at activation — after the invoice is consumed. + if plan.days > 3660 { + return Err(cfg_err(format!( + "PREMIUM_PLANS[{}] days {} exceeds the 3660-day (10-year) maximum", + plan.id, plan.days + ))); + } + } + } + Ok(()) } } @@ -1197,6 +1324,11 @@ mod tests { retention_days: 30, }, }, + premium: PremiumConfig { + enabled: false, + currency: String::new(), + plans: Vec::new(), + }, } } @@ -1506,6 +1638,130 @@ mod tests { 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. + fn wire_premium(c: &mut Config) { + c.messaging.relay = valid_relay(); + c.messaging.relay.write_policy = "premium-post".into(); + c.registration.payment = valid_payment(); + c.registration.payment.require_payment = false; // free to register; pay for premium + c.premium.enabled = true; + c.premium.currency = "XMR".into(); + c.premium.plans = vec![ + PremiumPlan { + id: "quarter".into(), + days: 90, + amount: "5".into(), + }, + PremiumPlan { + id: "year".into(), + days: 365, + amount: "15".into(), + }, + ]; + } + + #[test] + fn premium_off_skips_validation() { + let c = valid(); // premium disabled by default + assert!(c.validate().is_ok()); + } + + #[test] + fn premium_plans_parse_drops_malformed() { + // valid: quarter, year; dropped: "bad" (no fields), "x::5" (empty id), + // "y:0:5" (non-positive days), "z:9:" (empty amount). + let p = parse_premium_plans("quarter:90:5, year:365:15 ,bad, x::5, y:0:5, z:9:"); + assert_eq!(p.len(), 2); + assert_eq!( + (p[0].id.as_str(), p[0].days, p[0].amount.as_str()), + ("quarter", 90, "5") + ); + assert_eq!(p[1].id, "year"); + assert!(parse_premium_plans("").is_empty()); + } + + #[test] + fn premium_enabled_full_config_passes() { + let mut c = valid(); + wire_premium(&mut c); + assert!(c.validate().is_ok()); + } + + #[test] + fn premium_requires_relay_and_premium_post_policy() { + let mut c = valid(); + wire_premium(&mut c); + c.messaging.relay.enabled = false; // premium needs the relay on + assert!(c.validate().is_err()); + + let mut c = valid(); + wire_premium(&mut c); + c.messaging.relay.write_policy = "inbox-outbox".into(); // wrong policy + assert!(c.validate().is_err()); + } + + #[test] + fn premium_requires_plans_and_processor_creds() { + let mut c = valid(); + wire_premium(&mut c); + c.premium.plans.clear(); // no plans + assert!(c.validate().is_err()); + + let mut c = valid(); + wire_premium(&mut c); + c.registration.payment.provider_url = String::new(); // missing processor creds + assert!(c.validate().is_err()); + } + + #[test] + fn premium_rejects_duplicate_plan_ids() { + let mut c = valid(); + wire_premium(&mut c); + c.premium.plans = vec![ + PremiumPlan { + id: "dup".into(), + days: 90, + amount: "5".into(), + }, + PremiumPlan { + id: "dup".into(), + days: 365, + amount: "15".into(), + }, + ]; + assert!(c.validate().is_err()); + } + + #[test] + fn premium_rejects_zero_confirmations() { + // 0-conf lets a settled premium payment be double-spent after activation; + // the guard must hold on the premium path (require_payment is off here). + let mut c = valid(); + wire_premium(&mut c); + c.registration.payment.confirmations = 0; + assert!(c.validate().is_err()); + } + + #[test] + fn premium_rejects_absurd_plan_days() { + let mut c = valid(); + wire_premium(&mut c); + c.premium.plans[0].days = 900_000_000; // would overflow premium_until math + assert!(c.validate().is_err()); + } + + #[test] + fn premium_rejects_non_decimal_plan_amount() { + for bad in ["NaN", "inf", "1e5", "-1", "abc"] { + let mut c = valid(); + wire_premium(&mut c); + c.premium.plans[0].amount = bad.to_string(); + assert!(c.validate().is_err(), "amount {bad:?} must be rejected"); + } + } + #[test] fn relay_enabled_requires_url() { let mut c = valid(); diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index cfd76bb..74390fb 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -16,6 +16,7 @@ mod grin_slatepacks; mod invite_codes; mod login_events; mod payment_invoices; +mod premium_invoices; mod restore_attempts; mod server_config; mod sessions; @@ -26,6 +27,7 @@ pub use admin_keys::{AddKeyOutcome, RevokeKeyOutcome}; pub use challenges::ConsumedChallenge; pub use login_events::LoginStats; pub use payment_invoices::PaymentInvoiceRow; +pub use premium_invoices::PremiumInvoiceRow; pub use server_config::SetupState; use sqlx::PgPool; diff --git a/src/infra/db/premium_invoices.rs b/src/infra/db/premium_invoices.rs new file mode 100644 index 0000000..19318e8 --- /dev/null +++ b/src/infra/db/premium_invoices.rs @@ -0,0 +1,156 @@ +//! Premium (subscription) invoice queries — the recurring counterpart to +//! `payment_invoices`. Binds a processor invoice id to the authenticated +//! `user_id` it was minted for and the plan's `period_days`, and enforces +//! **atomic single-use** on redemption (the same single-statement +//! `UPDATE ... WHERE consumed_at IS NULL ... RETURNING`, so two concurrent +//! completions can never both extend). Only public data lives here — never +//! funds or keys. + +use chrono::{DateTime, Utc}; +use tracing::instrument; +use uuid::Uuid; + +use crate::error::AppError; + +use super::Database; + +/// A persisted premium invoice: its binding, plan, and single-use state. +#[derive(Debug, Clone)] +pub struct PremiumInvoiceRow { + pub invoice_id: String, + pub user_id: Uuid, + pub plan_id: String, + pub period_days: i32, + pub amount: String, + pub currency: String, + /// `None` while unspent; set once when the invoice extends premium. + pub consumed_at: Option>, +} + +impl Database { + /// Record a freshly-created premium invoice, bound to `user_id` + its plan. + #[instrument(skip(self))] + #[allow(clippy::too_many_arguments)] + pub async fn insert_premium_invoice( + &self, + invoice_id: &str, + user_id: Uuid, + provider: &str, + plan_id: &str, + period_days: i32, + amount: &str, + currency: &str, + ) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO premium_invoices \ + (invoice_id, user_id, provider, plan_id, period_days, amount, currency) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(invoice_id) + .bind(user_id) + .bind(provider) + .bind(plan_id) + .bind(period_days) + .bind(amount) + .bind(currency) + .execute(self.pool()) + .await?; + Ok(()) + } + + /// Fetch a premium invoice by id (binding + plan + consumed state), or `None`. + #[instrument(skip(self))] + pub async fn get_premium_invoice( + &self, + invoice_id: &str, + ) -> Result, AppError> { + let row = sqlx::query_as::< + _, + ( + String, + Uuid, + String, + i32, + String, + String, + Option>, + ), + >( + "SELECT invoice_id, user_id, plan_id, period_days, amount, currency, consumed_at \ + FROM premium_invoices WHERE invoice_id = $1", + ) + .bind(invoice_id) + .fetch_optional(self.pool()) + .await?; + Ok(row.map( + |(invoice_id, user_id, plan_id, period_days, amount, currency, consumed_at)| { + PremiumInvoiceRow { + invoice_id, + user_id, + plan_id, + period_days, + amount, + currency, + consumed_at, + } + }, + )) + } + + /// How many unconsumed premium invoices this user currently holds — bounds how + /// many outstanding invoices one authenticated user can accrue at the processor + /// (served by the partial `premium_invoices_unconsumed_idx`). + #[instrument(skip(self))] + pub async fn count_unconsumed_premium_invoices(&self, user_id: Uuid) -> Result { + let n = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM premium_invoices WHERE user_id = $1 AND consumed_at IS NULL", + ) + .bind(user_id) + .fetch_one(self.pool()) + .await?; + Ok(n) + } + + /// Atomically consume a single-use premium invoice (bound to `user_id`) AND + /// extend the user's premium window by `days`, in ONE transaction — so a + /// transient failure between the two rolls the consume back and the user can + /// retry, instead of burning a paid invoice with no grant. Returns the new + /// expiry if THIS call consumed the invoice; `None` if it was already consumed + /// or not bound to this user (neither side happens). Concurrent completions + /// race safely on the `consumed_at IS NULL` predicate — exactly one wins. + #[instrument(skip(self))] + pub async fn activate_premium( + &self, + invoice_id: &str, + user_id: Uuid, + days: i32, + ) -> Result>, AppError> { + let mut tx = self.pool().begin().await?; + let consumed = sqlx::query_scalar::<_, String>( + "UPDATE premium_invoices SET consumed_at = NOW() \ + WHERE invoice_id = $1 AND user_id = $2 AND consumed_at IS NULL \ + RETURNING invoice_id", + ) + .bind(invoice_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + if consumed.is_none() { + tx.rollback().await?; + return Ok(None); + } + let until = sqlx::query_scalar::<_, DateTime>( + "UPDATE users SET \ + premium_until = GREATEST(COALESCE(premium_until, NOW()), NOW()) \ + + make_interval(days => $2), \ + updated_at = NOW() \ + WHERE id = $1 RETURNING premium_until", + ) + .bind(user_id) + .bind(days) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(until)) + } +} diff --git a/src/infra/db/users.rs b/src/infra/db/users.rs index 01175e3..3b27311 100644 --- a/src/infra/db/users.rs +++ b/src/infra/db/users.rs @@ -145,6 +145,61 @@ impl Database { Ok(exists) } + /// Whether an x-only pubkey (canonical lowercase hex) is a registered npub + /// with a CURRENTLY-ACTIVE premium subscription — the membership check behind + /// the `premium-post` relay policy. Not peppered (npub is public); safe per + /// inbound relay event. + #[instrument(skip(self, nostr_pubkey))] + pub async fn is_premium_npub(&self, nostr_pubkey: &str) -> Result { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM users \ + WHERE nostr_pubkey = $1 AND premium_until IS NOT NULL AND premium_until > NOW())", + ) + .bind(nostr_pubkey) + .fetch_one(self.pool()) + .await?; + Ok(exists) + } + + /// Extend a user's premium window by `days`, stacking from the later of `NOW()` + /// and the current expiry (so an early renewal never shortens the window). + /// Returns the new expiry. + #[instrument(skip(self))] + pub async fn extend_premium( + &self, + user_id: Uuid, + days: i32, + ) -> Result, AppError> { + let until = sqlx::query_scalar::<_, chrono::DateTime>( + "UPDATE users SET \ + premium_until = GREATEST(COALESCE(premium_until, NOW()), NOW()) \ + + make_interval(days => $2), \ + updated_at = NOW() \ + WHERE id = $1 RETURNING premium_until", + ) + .bind(user_id) + .bind(days) + .fetch_one(self.pool()) + .await?; + Ok(until) + } + + /// A user's current premium expiry (`None` if never premium / lapsed to NULL). + #[instrument(skip(self))] + pub async fn get_premium_until( + &self, + user_id: Uuid, + ) -> Result>, AppError> { + let until = sqlx::query_scalar::<_, Option>>( + "SELECT premium_until FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(self.pool()) + .await? + .flatten(); + Ok(until) + } + /// Replace a user's `pubkey_hash` (derivation-scheme rotation, keyed by the /// unchanged `seed_fingerprint`). Peppered. #[instrument(skip(self, new_pubkey_hash))] diff --git a/src/infra/payment/mod.rs b/src/infra/payment/mod.rs index 8baaa5b..590cadb 100644 --- a/src/infra/payment/mod.rs +++ b/src/infra/payment/mod.rs @@ -112,7 +112,9 @@ pub trait PaymentProvider: Send + Sync { /// validation already rejects it when the gate is on, so this is defensive). pub fn from_config(cfg: &Config) -> Result>, AppError> { let p = &cfg.registration.payment; - if !p.require_payment { + // Build the provider when the pay-to-register gate OR the premium tier needs + // it (premium reuses the same processor for its recurring invoices). + if !p.require_payment && !cfg.premium.enabled { return Ok(None); } let provider: Arc = match p.provider.as_str() { diff --git a/src/infra/relay/nauthz.rs b/src/infra/relay/nauthz.rs index 38be10c..bf9cfd5 100644 --- a/src/infra/relay/nauthz.rs +++ b/src/infra/relay/nauthz.rs @@ -15,7 +15,7 @@ use tonic::{Request, Response, Status}; use crate::infra::db::Database; -use super::{decide, Admit, EventMeta, RelayProvider}; +use super::{decide, Admit, EventMeta, RelayProvider, WritePolicy}; /// Generated from `proto/nauthz.proto` (see `build.rs`). pub mod proto { @@ -80,6 +80,14 @@ impl Authorization for AdmissionService { .await .unwrap_or(false); + // Premium membership — only the `premium-post` policy consults it, so skip + // the (cheap, indexed) lookup entirely under the other policies. + let author_premium = if self.relay.write_policy() == WritePolicy::PremiumPost { + self.db.is_premium_npub(&author_hex).await.unwrap_or(false) + } else { + false + }; + // Recipient registration (only when the author isn't already registered): // collect up to MAX_P_TAGS distinct `p` tags and resolve them in ONE // batched query — bounds the work so a crafted event stuffed with `p` tags @@ -114,6 +122,7 @@ impl Authorization for AdmissionService { self.relay.inbound_pow_bits(), author_registered, recipient_registered, + author_premium, ) { Admit::Permit => permit(), Admit::Deny(msg) => deny(msg), diff --git a/src/infra/relay/policy.rs b/src/infra/relay/policy.rs index b3b20c1..7e512a4 100644 --- a/src/infra/relay/policy.rs +++ b/src/infra/relay/policy.rs @@ -11,16 +11,35 @@ //! (outbox); anyone may deliver a NIP-17 gift-wrap (kind 1059) ADDRESSED to a //! registered user (inbox), optionally behind a NIP-13 proof-of-work gate to //! blunt cross-ecosystem spam. Everything else is rejected. +//! * `premium-post` — registered users publish `WALLET_KINDS` (DMs, tips, swap +//! coordination) free; a general (non-wallet) event requires the author to hold +//! an active premium subscription. Inbox delivery works as in `inbox-outbox`. /// NIP-59 gift-wrap event kind (the sealed envelope carrying a NIP-17 DM). pub const GIFT_WRAP_KIND: u64 = 1059; +/// Event kinds that make the Smirk wallet + its peer features work — free to +/// publish for any registered user under the `premium-post` policy (premium +/// unlocks *general* Nostr posting on top). Extensible as tip / atomic-swap +/// coordination kinds define their on-Nostr shape. +pub const WALLET_KINDS: &[u64] = &[ + GIFT_WRAP_KIND, // 1059 — NIP-17 encrypted DMs (incl. tip + swap payloads) + 10050, // NIP-17 DM relay list (where a user receives DMs) +]; + +fn is_wallet_kind(kind: u64) -> bool { + WALLET_KINDS.contains(&kind) +} + /// Operator write policy. Parsed from the validated `RELAY_WRITE_POLICY` string. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WritePolicy { Open, AuthorAllowlist, InboxOutbox, + /// Wallet events free for registered users; general Nostr posting requires an + /// active premium subscription. + PremiumPost, } impl WritePolicy { @@ -31,6 +50,7 @@ impl WritePolicy { "open" => Some(Self::Open), "author-allowlist" => Some(Self::AuthorAllowlist), "inbox-outbox" => Some(Self::InboxOutbox), + "premium-post" => Some(Self::PremiumPost), _ => None, } } @@ -79,17 +99,35 @@ pub fn leading_zero_bits(id_hex: &str) -> u32 { count } +/// Inbox delivery of a NIP-17 gift-wrap (kind 1059) to a registered user, +/// optionally behind a NIP-13 PoW gate. Shared by `inbox-outbox` + `premium-post`. +fn giftwrap_inbox(meta: &EventMeta, inbound_pow_bits: u8, recipient_registered: bool) -> Admit { + if meta.kind == GIFT_WRAP_KIND && recipient_registered { + // Optionally require proof-of-work to blunt cross-ecosystem spam. + if inbound_pow_bits > 0 && leading_zero_bits(meta.id_hex) < u32::from(inbound_pow_bits) { + Admit::Deny("insufficient proof-of-work for cross-ecosystem delivery") + } else { + Admit::Permit + } + } else { + Admit::Deny("external authors may only deliver gift-wrapped DMs to registered users") + } +} + /// Decide whether to admit an event. /// /// `author_registered` = the author pubkey is a registered Smirk npub. /// `recipient_registered` = at least one `p` tag is a registered Smirk npub. -/// (The caller resolves both against the DB before calling.) +/// `author_premium` = the author holds an active premium subscription (only the +/// `premium-post` policy consults it). The caller resolves all three against the +/// DB before calling. pub fn decide( meta: &EventMeta, policy: WritePolicy, inbound_pow_bits: u8, author_registered: bool, recipient_registered: bool, + author_premium: bool, ) -> Admit { match policy { WritePolicy::Open => Admit::Permit, @@ -104,20 +142,25 @@ pub fn decide( if author_registered { // Outbox: a registered user publishing their own events. Admit::Permit - } else if meta.kind == GIFT_WRAP_KIND && recipient_registered { - // Inbox: an external author delivering a gift-wrapped DM to one of - // our users. Optionally require proof-of-work to blunt spam. - if inbound_pow_bits > 0 - && leading_zero_bits(meta.id_hex) < u32::from(inbound_pow_bits) - { - Admit::Deny("insufficient proof-of-work for cross-ecosystem delivery") - } else { - Admit::Permit - } } else { + giftwrap_inbox(meta, inbound_pow_bits, recipient_registered) + } + } + WritePolicy::PremiumPost => { + if author_premium { + // Premium member: the general-purpose relay — any kind. + Admit::Permit + } else if author_registered && is_wallet_kind(meta.kind) { + // Free for registered users: the events that make the wallet work. + Admit::Permit + } else if author_registered { + // Registered, but a general (non-wallet) event without premium. Admit::Deny( - "external authors may only deliver gift-wrapped DMs to registered users", + "premium membership required to post general Nostr events to this relay", ) + } else { + // External author: only gift-wrapped DM inbox delivery, as inbox-outbox. + giftwrap_inbox(meta, inbound_pow_bits, recipient_registered) } } } @@ -151,44 +194,48 @@ mod tests { WritePolicy::parse("inbox-outbox"), Some(WritePolicy::InboxOutbox) ); + assert_eq!( + WritePolicy::parse("premium-post"), + Some(WritePolicy::PremiumPost) + ); assert_eq!(WritePolicy::parse("nope"), None); } #[test] fn open_permits_everything() { let m = meta(A, 1, FF); - assert!(decide(&m, WritePolicy::Open, 0, false, false).is_permit()); + assert!(decide(&m, WritePolicy::Open, 0, false, false, false).is_permit()); } #[test] fn author_allowlist_gates_on_author() { let m = meta(A, 1, FF); - assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false).is_permit()); - assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false).is_permit()); + assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false, false).is_permit()); + assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false, false).is_permit()); } #[test] fn inbox_outbox_permits_registered_author_outbox() { let m = meta(A, 1, FF); // any kind, registered author - assert!(decide(&m, WritePolicy::InboxOutbox, 0, true, false).is_permit()); + assert!(decide(&m, WritePolicy::InboxOutbox, 0, true, false, false).is_permit()); } #[test] fn inbox_outbox_permits_giftwrap_to_registered_recipient() { let m = meta(A, GIFT_WRAP_KIND, FF); - assert!(decide(&m, WritePolicy::InboxOutbox, 0, false, true).is_permit()); + assert!(decide(&m, WritePolicy::InboxOutbox, 0, false, true, false).is_permit()); } #[test] fn inbox_outbox_rejects_external_non_giftwrap() { let m = meta(A, 1, FF); // external author, non-gift-wrap - assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, true).is_permit()); + assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, true, false).is_permit()); } #[test] fn inbox_outbox_rejects_giftwrap_to_unregistered_recipient() { let m = meta(A, GIFT_WRAP_KIND, FF); - assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, false).is_permit()); + assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, false, false).is_permit()); } #[test] @@ -205,8 +252,8 @@ mod tests { let id = format!("0000{}", "ff".repeat(30)); let m = meta(A, GIFT_WRAP_KIND, &id); // require 8 bits → permit; require 20 → deny. - assert!(decide(&m, WritePolicy::InboxOutbox, 8, false, true).is_permit()); - assert!(!decide(&m, WritePolicy::InboxOutbox, 20, false, true).is_permit()); + assert!(decide(&m, WritePolicy::InboxOutbox, 8, false, true, false).is_permit()); + assert!(!decide(&m, WritePolicy::InboxOutbox, 20, false, true, false).is_permit()); } #[test] @@ -214,6 +261,46 @@ mod tests { // A registered author's low-PoW event is still fine (PoW is inbound-only). let id = "ff".repeat(32); // 0 leading zero bits let m = meta(A, 1, &id); - assert!(decide(&m, WritePolicy::InboxOutbox, 20, true, false).is_permit()); + assert!(decide(&m, WritePolicy::InboxOutbox, 20, true, false, false).is_permit()); + } + + // ── premium-post ───────────────────────────────────────────────────────── + + #[test] + fn premium_post_premium_author_posts_anything() { + let m = meta(A, 1, FF); // a general kind-1 note + // premium → permit even a non-wallet kind. + assert!(decide(&m, WritePolicy::PremiumPost, 0, true, false, true).is_permit()); + } + + #[test] + fn premium_post_registered_gets_wallet_kinds_free() { + for k in WALLET_KINDS { + let m = meta(A, *k, FF); + // registered, NOT premium → wallet kinds still permitted. + assert!( + decide(&m, WritePolicy::PremiumPost, 0, true, false, false).is_permit(), + "wallet kind {k} should be free for registered users" + ); + } + } + + #[test] + fn premium_post_registered_needs_premium_for_general() { + let m = meta(A, 1, FF); // general kind-1, registered but not premium + assert!(!decide(&m, WritePolicy::PremiumPost, 0, true, false, false).is_permit()); + } + + #[test] + fn premium_post_allows_external_giftwrap_inbox() { + let m = meta(A, GIFT_WRAP_KIND, FF); + // external (unregistered) author delivering a DM to a registered user. + assert!(decide(&m, WritePolicy::PremiumPost, 0, false, true, false).is_permit()); + } + + #[test] + fn premium_post_rejects_external_general() { + let m = meta(A, 1, FF); // external author, non-gift-wrap + assert!(!decide(&m, WritePolicy::PremiumPost, 0, false, true, false).is_permit()); } } diff --git a/src/lib.rs b/src/lib.rs index dabffe4..6435acf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -113,6 +113,7 @@ pub fn build_router(state: Arc) -> Router { .merge(api::prices::routes()) .merge(api::landing::routes()) .merge(api::erasure::routes()) + .merge(api::premium::routes()) .layer(normal), ); diff --git a/tests/premium.rs b/tests/premium.rs new file mode 100644 index 0000000..599c9f6 --- /dev/null +++ b/tests/premium.rs @@ -0,0 +1,153 @@ +//! Premium tier — L1 integration (the payment-critical DB layer + feature gate). +//! +//! Self-skips when `TEST_DATABASE_URL` is unset (see `tests/common`). The pure +//! admission logic is unit-tested in `infra::relay::policy`; these cover the parts +//! that only surface against a real database: single-use activation, stacking, +//! cross-user binding, membership-by-expiry, and the endpoint feature gate. + +mod common; + +use axum::http::StatusCode; +use serde_json::json; +use smirk_backend_core::models::db::NewUser; +use uuid::Uuid; + +fn new_user_with_npub(npub: Option) -> NewUser { + NewUser { + username: None, + pubkey_hash: Some(format!("pk-{}", Uuid::new_v4())), + nostr_pubkey: npub, + wallet_birthday: None, + seed_fingerprint: None, + xmr_start_height: None, + wow_start_height: None, + } +} + +#[tokio::test] +async fn activate_premium_is_single_use_and_stacks() { + let app = require_app!(); + let db = &app.state.db; + let user_id = app.create_user().await; + + db.insert_premium_invoice("inv-a", user_id, "btcpay", "quarter", 90, "5", "USD") + .await + .unwrap(); + db.insert_premium_invoice("inv-b", user_id, "btcpay", "quarter", 90, "5", "USD") + .await + .unwrap(); + assert_eq!( + db.count_unconsumed_premium_invoices(user_id).await.unwrap(), + 2 + ); + + // First activation grants a future expiry. + let until1 = db + .activate_premium("inv-a", user_id, 90) + .await + .unwrap() + .expect("first activation grants"); + assert!(until1 > chrono::Utc::now()); + + // Re-activating the SAME invoice is a no-op (single-use) — no double grant. + assert!(db + .activate_premium("inv-a", user_id, 90) + .await + .unwrap() + .is_none()); + + // A second invoice STACKS from the current expiry. + let until2 = db + .activate_premium("inv-b", user_id, 90) + .await + .unwrap() + .expect("second activation grants"); + assert!(until2 > until1, "second plan extends the window"); + + assert_eq!(db.get_premium_until(user_id).await.unwrap(), Some(until2)); + assert_eq!( + db.count_unconsumed_premium_invoices(user_id).await.unwrap(), + 0 + ); +} + +#[tokio::test] +async fn activate_premium_rejects_cross_user_invoice() { + let app = require_app!(); + let db = &app.state.db; + let owner = app.create_user().await; + let attacker = app.create_user().await; + db.insert_premium_invoice("inv-x", owner, "btcpay", "quarter", 90, "5", "USD") + .await + .unwrap(); + + // Another user cannot activate the owner's invoice; it stays unconsumed. + assert!(db + .activate_premium("inv-x", attacker, 90) + .await + .unwrap() + .is_none()); + assert_eq!( + db.count_unconsumed_premium_invoices(owner).await.unwrap(), + 1 + ); + assert!(db.get_premium_until(attacker).await.unwrap().is_none()); +} + +#[tokio::test] +async fn is_premium_npub_respects_membership_and_expiry() { + let app = require_app!(); + let db = &app.state.db; + let npub = "a".repeat(64); + let user = db + .create_user(new_user_with_npub(Some(npub.clone()))) + .await + .unwrap(); + + // Not premium until granted. + assert!(!db.is_premium_npub(&npub).await.unwrap()); + db.extend_premium(user.id, 90).await.unwrap(); + assert!(db.is_premium_npub(&npub).await.unwrap()); + // An unrelated npub is never premium. + assert!(!db.is_premium_npub(&"b".repeat(64)).await.unwrap()); +} + +#[tokio::test] +async fn premium_endpoints_gated_off_by_default() { + let app = require_app!(); + let (_uid, access, _refresh) = app.mint_session().await; + + // Premium disabled in the default test config → invoice/activate are 400. + let (s, _) = app + .request( + "POST", + "/api/v1/premium/invoice", + Some(&access), + Some(json!({ "plan": "quarter" })), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + + let (s, _) = app + .request( + "POST", + "/api/v1/premium/activate", + Some(&access), + Some(json!({ "invoice_id": "x" })), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + + // Status is always readable → not premium. + let (s, body) = app + .request("GET", "/api/v1/premium/status", Some(&access), None) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!(body["active"], false); + + // No token → 401. + let (s, _) = app + .request("GET", "/api/v1/premium/status", None, None) + .await; + assert_eq!(s, StatusCode::UNAUTHORIZED); +}