Compare commits

..

3 Commits

Author SHA1 Message Date
userAdityaa a4b114535b feat: add Nostrord to recommended apps 2026-04-17 15:08:39 +05:45
userAdityaa 334f05783f chore: harden relay plan validation to prevent billing bypass and plan-state drift (#20)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 21:35:43 +00:00
userAdityaa 145b511f9d docs(auth): document intentional session-style NIP-98 model (#16)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:40:50 +00:00
5 changed files with 93 additions and 30 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>> {
+45 -3
View File
@@ -213,7 +213,7 @@ export default function Home() {
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Flotilla */}
<a
href="https://flotilla.social"
@@ -223,7 +223,7 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-blue-200">
<div class="w-12 h-12 rounded-2xl bg-linear-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-blue-200">
F
</div>
<div>
@@ -263,7 +263,7 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-purple-200">
<div class="w-12 h-12 rounded-2xl bg-linear-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-purple-200">
C
</div>
<div>
@@ -293,6 +293,47 @@ export default function Home() {
</span>
</div>
</a>
{/* Nostrord */}
<a
href="https://nostrord.com/"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-amber-300 hover:shadow-md transition-all"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-linear-to-br from-amber-500 to-orange-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-amber-200">
N
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-amber-600 transition-colors">Nostrord</h3>
<p class="text-xs text-gray-400">nostrord.com</p>
</div>
</div>
<span class="text-gray-300 group-hover:text-amber-400 transition-colors mt-1">
<ExternalLinkIcon />
</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">
A NIP-29 client built for decentralized group chat on Nostr. Create
censorship-resistant communities with admin roles, moderation, and access
controlall powered by your relay.
</p>
<div class="space-y-2">
{["Decentralized group chat with NIP-29", "Censorship-resistant communities", "Admin roles & moderation"].map(f => (
<div class="flex items-start gap-2 text-sm text-gray-600">
<CheckIcon />
{f}
</div>
))}
</div>
<div class="mt-auto pt-2">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-amber-600">
Visit nostrord.com <ExternalLinkIcon />
</span>
</div>
</a>
</div>
</section>
@@ -338,6 +379,7 @@ export default function Home() {
<div class="flex gap-4">
<a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a>
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
<a href="https://nostrord.com/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Nostrord</a>
</div>
</div>
</footer>