mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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.
154 lines
4.6 KiB
Rust
154 lines
4.6 KiB
Rust
//! 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);
|
|
}
|