From 6d651e2722c3dcfc057bc14abae9554f097d00b5 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 26 Mar 2026 12:59:29 -0700 Subject: [PATCH] Split api routes up --- backend/spec/api.md | 42 ++++++++---- backend/src/api.rs | 163 ++++++++++++++++++++++++++------------------ backend/src/repo.rs | 13 ++++ 3 files changed, 136 insertions(+), 82 deletions(-) diff --git a/backend/spec/api.md b/backend/spec/api.md index c2dc893..1fccc73 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -62,14 +62,31 @@ Notes: - If tenant is a duplicate, return a `422` with `code=pubkey-exists` - Return `data` is a single tenant struct. Use HTTP `201`. +## `async fn list_tenant_relays(...) -> Response` + +- Serves `GET /tenants/:pubkey/relays` +- Authorizes admin or matching tenant +- Return `data` is a list of relay structs from `repo.list_relays_for_tenant` + +## `async fn list_tenant_invoices(...) -> Response` + +- Serves `GET /tenants/:pubkey/invoices` +- Authorizes admin or matching tenant +- Return `data` is a list of invoice structs from `repo.list_invoices_for_tenant` + +## `async fn update_tenant_billing(...) -> Response` + +- Serves `PUT /tenants/:pubkey/billing` +- Authorizes admin or matching tenant +- Updates tenant billing NWC URL using `repo.update_tenant_nwc_url` +- Return `data` is the submitted billing payload + --- Relay routes ## `async fn list_relays(...) -> Response` -- Serves `GET /relays?tenant=` -- Authorizes admin or existing tenants -- If user is admin, `tenant` query parameter is optional -- If user is a tenant, `tenant` query parameter is not ok; authenticated `pubkey` is used +- Serves `GET /relays` +- Authorizes admin only - Return `data` is a list of relay structs from `repo.list_relays` ## `async fn get_relay(...) -> Response` @@ -103,22 +120,19 @@ Notes: - Deactivates relay using `repo.deactivate_relay` - Return `data` is empty ---- Billing routes +--- Invoice routes ## `async fn list_invoices(...) -> Response` -- Serves `GET /invoices?tenant=` -- Authorizes admin or existing tenants -- If user is admin, `tenant` query parameter is optional -- If user is a tenant, `tenant` query parameter is not ok; authenticated `pubkey` is used +- Serves `GET /invoices` +- Authorizes admin only - Return `data` is a list of invoice structs from `repo.list_invoices` -## `async fn update_tenant_billing(...) -> Response` +## `async fn get_invoice(...) -> Response` -- Serves `PUT /tenants/:pubkey/billing` -- Authorizes admin or matching tenant -- Updates tenant billing NWC URL using `repo.update_tenant_nwc_url` -- Return `data` is the submitted billing payload +- Serves `GET /invoices/:id` +- Authorizes admin or invoice owner +- Return `data` is a single invoice struct from `repo.get_invoice` # Utility functions diff --git a/backend/src/api.rs b/backend/src/api.rs index 336bd44..0fb2591 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::{Result, anyhow}; use axum::{ Json, Router, - extract::{Path, Query, State}, + extract::{Path, State}, http::{HeaderMap, Method, StatusCode, Uri}, response::{IntoResponse, Response}, routing::{get, post, put}, @@ -80,11 +80,14 @@ impl Api { .route("/plans/:id", get(get_plan)) .route("/tenants", get(list_tenants).post(create_tenant)) .route("/tenants/:pubkey", get(get_tenant)) + .route("/tenants/:pubkey/relays", get(list_tenant_relays)) + .route("/tenants/:pubkey/invoices", get(list_tenant_invoices)) .route("/tenants/:pubkey/billing", put(update_tenant_billing)) .route("/relays", get(list_relays).post(create_relay)) .route("/relays/:id", get(get_relay).put(update_relay)) .route("/relays/:id/deactivate", post(deactivate_relay)) .route("/invoices", get(list_invoices)) + .route("/invoices/:id", get(get_invoice)) .with_state(state) .layer(self.cors_layer()); @@ -259,11 +262,6 @@ fn extract_auth_pubkey( Ok(event.pubkey.to_hex()) } -#[derive(Deserialize)] -struct TenantParam { - tenant: Option, -} - #[derive(Deserialize, Serialize)] struct UpdateTenantBillingRequest { nwc_url: String, @@ -423,43 +421,42 @@ async fn list_relays( headers: HeaderMap, method: Method, uri: Uri, - Query(query): Query, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri, &state.api.host) { + Ok(v) => v, + Err(e) => return auth_fail_response(e), + }; + if !state.api.is_admin(&pubkey) { + return err(StatusCode::FORBIDDEN, "forbidden", "admin required"); + } + + match state.api.repo.list_relays().await { + Ok(relays) => ok(StatusCode::OK, relays), + Err(e) => err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + ), + } +} + +async fn list_tenant_relays( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(pubkey): Path, ) -> Response { let auth = match extract_auth_pubkey(&headers, &method, &uri, &state.api.host) { Ok(v) => v, Err(e) => return auth_fail_response(e), }; - let tenant_filter = if state.api.is_admin(&auth) { - query.tenant.as_deref() - } else { - if state - .api - .repo - .get_tenant(&auth) - .await - .ok() - .flatten() - .is_none() - { - return err(StatusCode::FORBIDDEN, "forbidden", "tenant required"); - } - if query.tenant.is_some() { - return err( - StatusCode::BAD_REQUEST, - "tenant-query-not-allowed", - "tenant query is not allowed for tenant users", - ); - } - Some(auth.as_str()) - }; + if !(state.api.is_admin(&auth) || state.api.is_tenant(&auth, &pubkey)) { + return err(StatusCode::FORBIDDEN, "forbidden", "not authorized"); + } - let relays = match tenant_filter { - Some(tenant) => state.api.repo.list_relays_for_tenant(tenant).await, - None => state.api.repo.list_relays().await, - }; - - match relays { + match state.api.repo.list_relays_for_tenant(&pubkey).await { Ok(relays) => ok(StatusCode::OK, relays), Err(e) => err( StatusCode::INTERNAL_SERVER_ERROR, @@ -721,43 +718,16 @@ async fn list_invoices( headers: HeaderMap, method: Method, uri: Uri, - Query(query): Query, ) -> Response { - let auth = match extract_auth_pubkey(&headers, &method, &uri, &state.api.host) { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri, &state.api.host) { Ok(v) => v, Err(e) => return auth_fail_response(e), }; + if !state.api.is_admin(&pubkey) { + return err(StatusCode::FORBIDDEN, "forbidden", "admin required"); + } - let tenant_filter = if state.api.is_admin(&auth) { - query.tenant.as_deref() - } else { - if state - .api - .repo - .get_tenant(&auth) - .await - .ok() - .flatten() - .is_none() - { - return err(StatusCode::FORBIDDEN, "forbidden", "tenant required"); - } - if query.tenant.is_some() { - return err( - StatusCode::BAD_REQUEST, - "tenant-query-not-allowed", - "tenant query is not allowed for tenant users", - ); - } - Some(auth.as_str()) - }; - - let invoices = match tenant_filter { - Some(tenant) => state.api.repo.list_invoices_for_tenant(tenant).await, - None => state.api.repo.list_invoices().await, - }; - - match invoices { + match state.api.repo.list_invoices().await { Ok(invoices) => ok(StatusCode::OK, invoices), Err(e) => err( StatusCode::INTERNAL_SERVER_ERROR, @@ -767,6 +737,63 @@ async fn list_invoices( } } +async fn list_tenant_invoices( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(pubkey): Path, +) -> Response { + let auth = match extract_auth_pubkey(&headers, &method, &uri, &state.api.host) { + Ok(v) => v, + Err(e) => return auth_fail_response(e), + }; + + if !(state.api.is_admin(&auth) || state.api.is_tenant(&auth, &pubkey)) { + return err(StatusCode::FORBIDDEN, "forbidden", "not authorized"); + } + + match state.api.repo.list_invoices_for_tenant(&pubkey).await { + Ok(invoices) => ok(StatusCode::OK, invoices), + Err(e) => err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + ), + } +} + +async fn get_invoice( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, +) -> Response { + let auth = match extract_auth_pubkey(&headers, &method, &uri, &state.api.host) { + Ok(v) => v, + Err(e) => return auth_fail_response(e), + }; + + let invoice = match state.api.repo.get_invoice(&id).await { + Ok(Some(i)) => i, + Ok(None) => return err(StatusCode::NOT_FOUND, "not-found", "invoice not found"), + Err(e) => { + return err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + ); + } + }; + + if !(state.api.is_admin(&auth) || state.api.is_tenant(&auth, &invoice.tenant)) { + return err(StatusCode::FORBIDDEN, "forbidden", "not authorized"); + } + + ok(StatusCode::OK, invoice) +} + async fn update_tenant_billing( State(state): State, headers: HeaderMap, diff --git a/backend/src/repo.rs b/backend/src/repo.rs index c543ea0..d9d8ed6 100644 --- a/backend/src/repo.rs +++ b/backend/src/repo.rs @@ -382,6 +382,19 @@ impl Repo { Ok(rows) } + pub async fn get_invoice(&self, id: &str) -> Result> { + let row = sqlx::query_as::<_, Invoice>( + "SELECT id, tenant, status, created_at, attempted_at, error, closed_at, + sent_at, paid_at, bolt11, period_start, period_end + FROM invoice + WHERE id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } + pub async fn mark_invoice_paid(&self, invoice_id: &str) -> Result<()> { let mut tx = self.pool.begin().await?;