mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
feat(premium): recurring tiered relay-posting subscription (premium-post)
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.
This commit is contained in:
@@ -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;
|
||||
+248
@@ -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)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<u16>,
|
||||
}
|
||||
|
||||
/// 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<PremiumPlanInfo>,
|
||||
/// 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<MessagingCapability>,
|
||||
/// Premium tier details; present only when `features.premium_relay`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub premium: Option<PremiumCapability>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+11
-1
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<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 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<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"
|
||||
);
|
||||
|
||||
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<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);
|
||||
Ok(Json(StatusResp {
|
||||
active,
|
||||
premium_until: until.map(|u| u.to_rfc3339()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/premium/invoice", post(invoice))
|
||||
.route("/premium/activate", post(activate))
|
||||
.route("/premium/status", get(status))
|
||||
}
|
||||
+258
-2
@@ -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<PremiumPlan>,
|
||||
}
|
||||
|
||||
/// 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<PremiumPlan> {
|
||||
s.split(',')
|
||||
.filter_map(|entry| {
|
||||
let mut parts = entry.trim().splitn(3, ':');
|
||||
let id = parts.next()?.trim();
|
||||
let days = parts.next()?.trim().parse::<i32>().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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
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<Option<PremiumInvoiceRow>, AppError> {
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
Uuid,
|
||||
String,
|
||||
i32,
|
||||
String,
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
),
|
||||
>(
|
||||
"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<i64, AppError> {
|
||||
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<Option<DateTime<Utc>>, 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<Utc>>(
|
||||
"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))
|
||||
}
|
||||
}
|
||||
@@ -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<bool, AppError> {
|
||||
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<chrono::DateTime<chrono::Utc>, AppError> {
|
||||
let until = sqlx::query_scalar::<_, chrono::DateTime<chrono::Utc>>(
|
||||
"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<Option<chrono::DateTime<chrono::Utc>>, AppError> {
|
||||
let until = sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
|
||||
"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))]
|
||||
|
||||
@@ -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<Option<Arc<dyn PaymentProvider>>, 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<dyn PaymentProvider> = match p.provider.as_str() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+109
-22
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.merge(api::prices::routes())
|
||||
.merge(api::landing::routes())
|
||||
.merge(api::erasure::routes())
|
||||
.merge(api::premium::routes())
|
||||
.layer(normal),
|
||||
);
|
||||
|
||||
|
||||
@@ -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<String>) -> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user