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
+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);
}