Refactor api into different route files

This commit is contained in:
Jon Staab
2026-05-15 09:28:12 -07:00
parent 26f05e8b8f
commit 5590b14074
11 changed files with 1182 additions and 1109 deletions
+109 -1109
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -8,5 +8,7 @@ pub mod models;
pub mod pool;
pub mod query;
pub mod robot;
pub mod routes;
pub mod stripe;
pub mod wallet;
pub mod web;
+2
View File
@@ -8,8 +8,10 @@ mod models;
mod pool;
mod query;
mod robot;
mod routes;
mod stripe;
mod wallet;
mod web;
use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
+21
View File
@@ -0,0 +1,21 @@
use std::sync::Arc;
use axum::{extract::State, http::StatusCode, response::Response};
use serde::Serialize;
use crate::api::{Api, AuthedPubkey};
use crate::web::ok;
#[derive(Serialize)]
struct IdentityResponse {
pubkey: String,
is_admin: bool,
}
pub async fn get_identity(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> Response {
let is_admin = api.is_admin(&pubkey);
ok(StatusCode::OK, IdentityResponse { pubkey, is_admin })
}
+118
View File
@@ -0,0 +1,118 @@
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Response,
};
use crate::api::{Api, AuthedPubkey};
use crate::stripe::InvoiceLookupError;
use crate::web::{ApiError, err, ok};
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> Result<Response, ApiError> {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
match api
.billing
.stripe_list_invoices(&tenant.stripe_customer_id)
.await
{
Ok(invoices) => Ok(ok(StatusCode::OK, invoices)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
pub async fn get_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let (invoice, tenant) = api
.billing
.get_invoice_with_tenant(&id)
.await
.map_err(map_invoice_lookup_error)?;
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_manual_lightning_invoice(&id, &invoice)
.await
.map_err(map_invoice_lookup_error)?;
Ok(ok(StatusCode::OK, invoice))
}
pub async fn get_invoice_bolt11(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let (invoice, tenant) = api
.billing
.get_invoice_with_tenant(&id)
.await
.map_err(map_invoice_lookup_error)?;
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_manual_lightning_invoice(&id, &invoice)
.await
.map_err(map_invoice_lookup_error)?;
let status = invoice["status"].as_str().unwrap_or_default();
if status != "open" {
return Ok(err(
StatusCode::BAD_REQUEST,
"invoice-not-open",
"invoice is not open",
));
}
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
let currency = invoice["currency"].as_str().unwrap_or("usd");
match api
.billing
.get_or_create_manual_lightning_bolt11(&id, &tenant.pubkey, amount_due, currency)
.await
{
Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
match error {
InvoiceLookupError::StripeClient { status } => {
let status = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_REQUEST);
match status {
StatusCode::NOT_FOUND => ApiError::NotFound("invoice not found"),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
ApiError::Forbidden("invoice access denied")
}
_ => ApiError::Client {
status,
code: "invoice-request-rejected",
message: "invoice request rejected",
},
}
}
InvoiceLookupError::Internal(error) => ApiError::Internal(error.to_string()),
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod identity;
pub mod invoices;
pub mod plans;
pub mod relays;
pub mod stripe;
pub mod tenants;
+21
View File
@@ -0,0 +1,21 @@
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Response,
};
use crate::api::Api;
use crate::web::{err, ok};
pub async fn list_plans(State(api): State<Arc<Api>>) -> Response {
ok(StatusCode::OK, api.query.list_plans())
}
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> Response {
match api.query.get_plan(&id) {
Some(plan) => ok(StatusCode::OK, plan),
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
}
}
+546
View File
@@ -0,0 +1,546 @@
use std::sync::Arc;
use anyhow::Result;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::Response,
};
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
use crate::web::{ApiError, err, map_unique_error, ok, parse_bool_default};
#[derive(Deserialize)]
pub struct CreateRelayRequest {
pub tenant: String,
pub subdomain: String,
pub plan: String,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan: Option<String>,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
pub async fn list_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> Result<Response, ApiError> {
api.require_admin(&auth)?;
match api.query.list_relays().await {
Ok(relays) => Ok(ok(StatusCode::OK, relays)),
Err(e) => Err(ApiError::Internal(e.to_string())),
}
}
pub async fn get_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let relay = match api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
api.require_admin_or_tenant(&auth, &relay.tenant)?;
Ok(ok(StatusCode::OK, relay))
}
pub async fn list_relay_activity(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let relay = match api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
api.require_admin_or_tenant(&auth, &relay.tenant)?;
match api.query.list_activity_for_relay(&id).await {
Ok(activity) => Ok(ok(
StatusCode::OK,
serde_json::json!({ "activity": activity }),
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
pub async fn list_relay_members(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let relay = match api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
api.require_admin_or_tenant(&auth, &relay.tenant)?;
match fetch_relay_members(&api, &relay).await {
Ok(members) => Ok(ok(
StatusCode::OK,
serde_json::json!({ "members": members }),
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
pub async fn create_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Json(payload): Json<CreateRelayRequest>,
) -> Result<Response, ApiError> {
api.require_admin_or_tenant(&auth, &payload.tenant)?;
let relay_id = format!(
"{}_{}",
payload.subdomain.replace('-', "_"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
);
let mut relay = Relay {
id: relay_id.clone(),
tenant: payload.tenant,
schema: relay_id.clone(),
subdomain: payload.subdomain,
plan: payload.plan,
stripe_subscription_item_id: None,
status: RELAY_STATUS_ACTIVE.to_string(),
sync_error: String::new(),
info_name: payload.info_name.unwrap_or_default(),
info_icon: payload.info_icon.unwrap_or_default(),
info_description: payload.info_description.unwrap_or_default(),
policy_public_join: payload.policy_public_join.unwrap_or(0),
policy_strip_signatures: payload.policy_strip_signatures.unwrap_or(0),
groups_enabled: payload.groups_enabled.unwrap_or(1),
management_enabled: payload.management_enabled.unwrap_or(1),
blossom_enabled: payload.blossom_enabled.unwrap_or(0),
livekit_enabled: payload.livekit_enabled.unwrap_or(0),
push_enabled: payload.push_enabled.unwrap_or(1),
synced: 0,
};
relay = match prepare_relay(&api, relay) {
Ok(r) => r,
Err(e) => {
return Ok(relay_validation_error_response(e));
}
};
match api.command.create_relay(&relay).await {
Ok(()) => Ok(ok(StatusCode::CREATED, relay)),
Err(e) => {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"subdomain-exists",
"subdomain already exists",
))
} else {
Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
))
}
}
}
}
pub async fn update_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
Json(payload): Json<UpdateRelayRequest>,
) -> Result<Response, ApiError> {
let mut relay = match api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone();
let requested_plan = payload.plan.clone();
if let Some(v) = payload.subdomain {
relay.subdomain = v;
}
if let Some(v) = requested_plan.clone() {
relay.plan = v;
}
if let Some(v) = payload.info_name {
relay.info_name = v;
}
if let Some(v) = payload.info_icon {
relay.info_icon = v;
}
if let Some(v) = payload.info_description {
relay.info_description = v;
}
if let Some(v) = payload.policy_public_join {
relay.policy_public_join = v;
}
if let Some(v) = payload.policy_strip_signatures {
relay.policy_strip_signatures = v;
}
if let Some(v) = payload.groups_enabled {
relay.groups_enabled = v;
}
if let Some(v) = payload.management_enabled {
relay.management_enabled = v;
}
if let Some(v) = payload.blossom_enabled {
relay.blossom_enabled = v;
}
if let Some(v) = payload.livekit_enabled {
relay.livekit_enabled = v;
}
if let Some(v) = payload.push_enabled {
relay.push_enabled = v;
}
relay = match prepare_relay(&api, relay) {
Ok(r) => r,
Err(e) => {
return Ok(relay_validation_error_response(e));
}
};
let plan_changed = requested_plan
.as_deref()
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan = api
.query
.get_plan(&relay.plan)
.expect("validated plan must exist");
if let Some(limit) = selected_plan.members {
let current_members = match fetch_relay_members(&api, &relay).await {
Ok(members) => members.len() as i64,
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
if current_members > limit {
let message = format!(
"relay has {current_members} members, which exceeds the {} plan limit of {limit}",
selected_plan.name.to_lowercase()
);
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"member-limit-exceeded",
&message,
));
}
}
}
match api.command.update_relay(&relay).await {
Ok(()) => Ok(ok(StatusCode::OK, relay)),
Err(e) => {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"subdomain-exists",
"subdomain already exists",
))
} else {
Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
))
}
}
}
}
pub async fn deactivate_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let relay = match api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT {
return Ok(err(
StatusCode::BAD_REQUEST,
"relay-is-inactive",
"relay is already inactive",
));
}
match api.command.deactivate_relay(&relay).await {
Ok(()) => Ok(ok(StatusCode::OK, ())),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
pub async fn reactivate_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let relay = match api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
));
}
};
api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == RELAY_STATUS_ACTIVE {
return Ok(err(
StatusCode::BAD_REQUEST,
"relay-is-active",
"relay is already active",
));
}
match api.command.activate_relay(&relay).await {
Ok(()) => Ok(ok(StatusCode::OK, ())),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
// --- helpers ----------------------------------------------------------------
async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result<Vec<String>> {
if relay.synced == 0 {
return Ok(Vec::new());
}
api.infra.list_relay_members(&relay.id).await
}
/// Validate user-supplied fields and fill defaults for `policy_*` / feature
/// flags. Premium feature flags are clamped against the plan's entitlements.
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, RelayValidationError> {
validate_subdomain_label(&relay.subdomain)?;
let plan = api
.query
.get_plan(&relay.plan)
.ok_or(RelayValidationError::InvalidPlan)?;
if !plan.blossom && relay.blossom_enabled == 1 {
return Err(RelayValidationError::PremiumFeature);
}
if !plan.livekit && relay.livekit_enabled == 1 {
return Err(RelayValidationError::PremiumFeature);
}
if relay.status.is_empty() {
relay.status = RELAY_STATUS_ACTIVE.to_string();
}
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
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 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)
}
const SUBDOMAIN_LABEL_MAX_LEN: usize = 63;
const RESERVED_SUBDOMAIN_LABELS: [&str; 2] = ["api", "admin"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubdomainValidationError {
Empty,
TooLong,
Reserved,
EdgeHyphen,
InvalidCharacters,
}
impl SubdomainValidationError {
fn code(self) -> &'static str {
match self {
Self::Empty => "subdomain-empty",
Self::TooLong => "subdomain-too-long",
Self::Reserved => "subdomain-reserved",
Self::EdgeHyphen => "subdomain-invalid-hyphen",
Self::InvalidCharacters => "subdomain-invalid-characters",
}
}
fn message(self) -> &'static str {
match self {
Self::Empty => "subdomain is required",
Self::TooLong => "subdomain must be 63 characters or fewer",
Self::Reserved => "subdomain is reserved",
Self::EdgeHyphen => "subdomain cannot start or end with a hyphen",
Self::InvalidCharacters => {
"subdomain may only contain lowercase letters, numbers, and hyphens"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RelayValidationError {
InvalidPlan,
PremiumFeature,
Subdomain(SubdomainValidationError),
}
impl RelayValidationError {
fn code(self) -> &'static str {
match self {
Self::InvalidPlan => "invalid-plan",
Self::PremiumFeature => "premium-feature",
Self::Subdomain(reason) => reason.code(),
}
}
fn message(self) -> &'static str {
match self {
Self::InvalidPlan => "plan not found",
Self::PremiumFeature => "feature requires a paid plan",
Self::Subdomain(reason) => reason.message(),
}
}
}
impl From<SubdomainValidationError> for RelayValidationError {
fn from(value: SubdomainValidationError) -> Self {
Self::Subdomain(value)
}
}
fn validate_subdomain_label(subdomain: &str) -> Result<(), SubdomainValidationError> {
if subdomain.is_empty() {
return Err(SubdomainValidationError::Empty);
}
if subdomain.len() > SUBDOMAIN_LABEL_MAX_LEN {
return Err(SubdomainValidationError::TooLong);
}
if subdomain.starts_with('-') || subdomain.ends_with('-') {
return Err(SubdomainValidationError::EdgeHyphen);
}
if RESERVED_SUBDOMAIN_LABELS.contains(&subdomain) {
return Err(SubdomainValidationError::Reserved);
}
if !subdomain
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SubdomainValidationError::InvalidCharacters);
}
Ok(())
}
fn relay_validation_error_response(error: RelayValidationError) -> Response {
err(
StatusCode::UNPROCESSABLE_ENTITY,
error.code(),
error.message(),
)
}
+63
View File
@@ -0,0 +1,63 @@
use std::sync::Arc;
use axum::{
body::Bytes,
extract::{Path, Query as QueryParams, State},
http::{HeaderMap, StatusCode},
response::Response,
};
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::web::{ApiError, err, ok};
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
QueryParams(params): QueryParams<StripeSessionParams>,
) -> Result<Response, ApiError> {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
match api
.billing
.stripe_create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
{
Ok(url) => Ok(ok(StatusCode::OK, serde_json::json!({ "url": url }))),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
pub async fn stripe_webhook(
State(api): State<Arc<Api>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let signature = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let payload = match std::str::from_utf8(&body) {
Ok(s) => s,
Err(_) => return err(StatusCode::BAD_REQUEST, "bad-request", "invalid payload"),
};
match api.billing.handle_webhook(payload, signature).await {
Ok(()) => ok(StatusCode::OK, ()),
Err(e) => err(StatusCode::BAD_REQUEST, "webhook-error", &e.to_string()),
}
}
+203
View File
@@ -0,0 +1,203 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::Response,
};
use serde::{Deserialize, Serialize};
use chrono::Utc;
use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant;
use crate::web::{ApiError, err, map_unique_error, ok};
#[derive(Serialize)]
pub struct TenantResponse {
pub pubkey: String,
pub nwc_is_set: bool,
pub nwc_error: Option<String>,
pub created_at: i64,
pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
}
impl From<Tenant> for TenantResponse {
fn from(t: Tenant) -> Self {
TenantResponse {
nwc_is_set: !t.nwc_url.is_empty(),
pubkey: t.pubkey,
nwc_error: t.nwc_error,
created_at: t.created_at,
stripe_customer_id: t.stripe_customer_id,
stripe_subscription_id: t.stripe_subscription_id,
past_due_at: t.past_due_at,
}
}
}
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn list_tenants(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> Result<Response, ApiError> {
api.require_admin(&auth)?;
match api.query.list_tenants().await {
Ok(tenants) => Ok(ok(
StatusCode::OK,
tenants
.into_iter()
.map(TenantResponse::from)
.collect::<Vec<_>>(),
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
/// already exists (including a unique-constraint race) we return the existing
/// row.
pub async fn create_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> Result<Response, ApiError> {
match api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => Ok(ok(StatusCode::OK, TenantResponse::from(t))),
Ok(None) => {
let stripe_customer_id = match api.billing.stripe_create_customer(&pubkey).await {
Ok(id) => id,
Err(e) => {
return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"stripe-customer-create-failed",
&e.to_string(),
));
}
};
let tenant = Tenant {
pubkey: pubkey.clone(),
nwc_url: String::new(),
nwc_error: None,
created_at: Utc::now().timestamp(),
stripe_customer_id,
stripe_subscription_id: None,
past_due_at: None,
};
match api.command.create_tenant(&tenant).await {
Ok(()) => Ok(ok(StatusCode::OK, TenantResponse::from(tenant))),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => Ok(ok(StatusCode::OK, TenantResponse::from(t))),
Ok(None) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
"tenant row missing after unique-constraint race",
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
pub async fn get_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> Result<Response, ApiError> {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
Ok(ok(StatusCode::OK, TenantResponse::from(tenant)))
}
pub async fn update_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
Json(payload): Json<UpdateTenantRequest>,
) -> Result<Response, ApiError> {
api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
let nwc_previously_empty = tenant.nwc_url.is_empty();
if let Some(nwc_url) = payload.nwc_url {
if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url = api
.env
.encrypt(&nwc_url)
.map_err(|e| ApiError::Internal(e.to_string()))?;
}
}
match api.command.update_tenant(&tenant).await {
Ok(()) => {
// When NWC is first connected, attempt to pay any outstanding open invoices.
if nwc_previously_empty && !tenant.nwc_url.is_empty() {
let billing = api.billing.clone();
let tenant_clone = tenant.clone();
tokio::spawn(async move {
if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await {
tracing::error!(
error = %e,
pubkey = %tenant_clone.pubkey,
"pay_outstanding_nwc_invoices failed after NWC setup"
);
}
});
}
Ok(ok(StatusCode::OK, TenantResponse::from(tenant)))
}
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
pub async fn list_tenant_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> Result<Response, ApiError> {
api.require_admin_or_tenant(&auth, &pubkey)?;
match api.query.list_relays_for_tenant(&pubkey).await {
Ok(relays) => Ok(ok(StatusCode::OK, relays)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
}
+91
View File
@@ -0,0 +1,91 @@
//! General-purpose HTTP helpers shared across route handlers.
//!
//! This module owns the wire response envelopes (`ok` / `err`), the
//! `ApiError` type that route handlers return, and a few stateless utilities.
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Serialize)]
pub struct OkResponse<T: Serialize> {
pub data: T,
pub code: &'static str,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub code: String,
}
#[derive(Debug)]
pub enum ApiError {
Unauthorized(anyhow::Error),
Forbidden(&'static str),
NotFound(&'static str),
Client {
status: StatusCode,
code: &'static str,
message: &'static str,
},
Internal(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
Self::Unauthorized(e) => err(StatusCode::UNAUTHORIZED, "unauthorized", &e.to_string()),
Self::Forbidden(message) => err(StatusCode::FORBIDDEN, "forbidden", message),
Self::NotFound(message) => err(StatusCode::NOT_FOUND, "not-found", message),
Self::Client {
status,
code,
message,
} => err(status, code, message),
Self::Internal(message) => err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &message),
}
}
}
pub fn ok<T: Serialize>(status: StatusCode, data: T) -> Response {
(status, Json(OkResponse { data, code: "ok" })).into_response()
}
pub fn err(status: StatusCode, code: &str, message: &str) -> Response {
(
status,
Json(ErrorResponse {
error: message.to_string(),
code: code.to_string(),
}),
)
.into_response()
}
pub fn parse_bool_default(value: i64, default: i64) -> i64 {
if value == 0 || value == 1 {
value
} else {
default
}
}
/// Recognize sqlite UNIQUE constraint violations on known columns so the
/// caller can translate them into 422 responses instead of opaque 500s.
pub fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> {
let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
let sqlx::Error::Database(db_err) = sqlx_err else {
return None;
};
if db_err.message().contains("pubkey") {
return Some("pubkey-exists");
}
if db_err.message().contains("subdomain") {
return Some("subdomain-exists");
}
None
}