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:
jwinterm
2026-07-02 17:56:44 -04:00
parent b4935b15e8
commit eb98e4d625
15 changed files with 1339 additions and 27 deletions
+26
View File
@@ -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
View File
@@ -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)."
}
]
}
+46
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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;
+260
View File
@@ -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,
&currency,
)
.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
View File
@@ -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();
+2
View File
@@ -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;
+156
View File
@@ -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))
}
}
+55
View File
@@ -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))]
+3 -1
View File
@@ -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() {
+10 -1
View File
@@ -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
View File
@@ -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());
}
}
+1
View File
@@ -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),
);
+153
View File
@@ -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);
}