chore: harden relay plan validation to prevent billing bypass and plan-state drift #20

Merged
hodlbod merged 1 commits from userAdityaa/caravel:harden-plan-validation into master 2026-04-16 21:35:44 +00:00
4 changed files with 48 additions and 27 deletions
+3 -2
View File
@@ -204,7 +204,8 @@ Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. U
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
- Validate `subdomain`
- If `plan` is free and `blossom` is enabled, return `premium-feature`
- If `plan` is free and `livekit` is enabled, return `premium-feature`
- Validate that `plan` matches a known plan id from `Query::list_plans`
- If selected `plan` does not include `blossom` and `blossom` is enabled, return `premium-feature`
- If selected `plan` does not include `livekit` and `livekit` is enabled, return `premium-feature`
- Populate `schema` if not already set
- Populate missing fields using reasonable defaults
+23 -11
View File
@@ -259,10 +259,12 @@ impl Api {
return Err(anyhow!("invalid-subdomain"));
}
if relay.plan == "free" && relay.blossom_enabled == 1 {
let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
if !plan.blossom && relay.blossom_enabled == 1 {
return Err(anyhow!("premium-feature"));
}
if relay.plan == "free" && relay.livekit_enabled == 1 {
if !plan.livekit && relay.livekit_enabled == 1 {
return Err(anyhow!("premium-feature"));
}
@@ -276,14 +278,10 @@ impl Api {
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
relay.blossom_enabled = parse_bool_default(
relay.blossom_enabled,
if relay.plan == "free" { 0 } else { 1 },
);
relay.livekit_enabled = parse_bool_default(
relay.livekit_enabled,
if relay.plan == "free" { 0 } else { 1 },
);
relay.blossom_enabled =
parse_bool_default(relay.blossom_enabled, if plan.blossom { 1 } else { 0 });
relay.livekit_enabled =
parse_bool_default(relay.livekit_enabled, if plan.livekit { 1 } else { 0 });
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
Ok(relay)
@@ -453,7 +451,7 @@ async fn get_identity(
}
async fn get_plan(Path(id): Path<String>) -> Response {
match Query::list_plans().into_iter().find(|p| p.id == id) {
match Query::get_plan(&id) {
Some(plan) => ok(StatusCode::OK, plan),
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
}
@@ -594,6 +592,13 @@ async fn create_relay(
};
relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => {
return Ok(err(
@@ -691,6 +696,13 @@ async fn update_relay(
}
relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => {
return Ok(err(
+7 -10
View File
@@ -154,8 +154,11 @@ impl Billing {
return Ok(());
};
let plan = Query::get_plan(&relay.plan)
.ok_or_else(|| anyhow!("unknown relay plan id: {}", relay.plan))?;
// Free plan: remove subscription item if exists, then clean up
if relay.plan == "free" {
if plan.id == "free" {
if let Some(ref item_id) = relay.stripe_subscription_item_id {
self.stripe_delete_subscription_item(item_id).await?;
self.command
@@ -179,12 +182,6 @@ impl Billing {
}
// Active relay on a paid plan
let plan = Query::list_plans().into_iter().find(|p| p.id == relay.plan);
let Some(plan) = plan else {
return Ok(());
};
let Some(ref stripe_price_id) = plan.stripe_price_id else {
return Ok(());
};
@@ -442,7 +439,7 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" {
if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
self.command.mark_relay_delinquent(&relay).await?;
}
}
@@ -477,7 +474,7 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" {
if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
self.command.mark_relay_delinquent(&relay).await?;
}
}
@@ -801,7 +798,7 @@ impl Billing {
}
fn should_reactivate_after_payment(relay: &Relay) -> bool {
relay.status == RELAY_STATUS_DELINQUENT && relay.plan != "free"
relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
}
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
+15 -4
View File
@@ -68,6 +68,16 @@ impl Query {
]
}
pub fn get_plan(plan_id: &str) -> Option<Plan> {
Self::list_plans().into_iter().find(|p| p.id == plan_id)
}
pub fn is_paid_plan(plan_id: &str) -> bool {
Self::get_plan(plan_id)
.map(|p| p.id != "free")
.unwrap_or(false)
}
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
@@ -135,13 +145,14 @@ impl Query {
}
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
let count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM relay WHERE tenant = ? AND status = 'active' AND plan != 'free'",
let plans = sqlx::query_scalar::<_, String>(
"SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
)
.bind(tenant_id)
.fetch_one(&self.pool)
.fetch_all(&self.pool)
.await?;
Ok(count > 0)
Ok(plans.into_iter().any(|plan| Self::is_paid_plan(&plan)))
}
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {