Files
caravel/backend/src/api.rs
T
2026-05-27 17:26:47 -07:00

212 lines
7.2 KiB
Rust

//! HTTP surface: router assembly and authentication.
//!
//! This module owns the shared `Api`, the NIP-98 `AuthedPubkey`
//! extractor that decorates protected handlers, and a small set of
//! authorization helpers (`require_admin`, `require_admin_or_tenant`,
//! `get_tenant_or_404`) that route handlers in [`crate::routes`] call
//! once they have an authenticated pubkey.
//!
//! The route signature convention is:
//! - `State<Arc<Api>>` for shared services,
//! - `AuthedPubkey` if the route requires an authenticated caller,
//! - then path / query / body extractors.
//!
//! Authentication is enforced at the extractor boundary; authorization
//! (admin vs tenant ownership) is the handler's responsibility.
use std::sync::Arc;
use anyhow::{Result, anyhow, ensure};
use axum::{
Router,
async_trait,
extract::FromRequestParts,
http::{HeaderMap, request::Parts},
routing::{get, post},
};
use base64::Engine;
use nostr_sdk::{Event, JsonUtil, Kind};
use crate::billing::Billing;
use crate::env;
use crate::models::{Relay, Tenant};
use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
use crate::routes::identity::get_identity;
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, get_tenant_latest_invoice};
use crate::routes::plans::{get_plan, list_plans};
use crate::routes::relays::{
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
list_relays, reactivate_relay, update_relay,
};
use crate::routes::tenants::{
create_stripe_session, create_tenant, get_tenant, list_tenant_invoices, list_tenant_relays,
list_tenants, update_tenant,
};
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
#[derive(Clone)]
pub struct Api {
pub billing: Billing,
pub stripe: Stripe,
pub robot: Robot,
}
impl Api {
pub fn new(billing: Billing, stripe: Stripe, robot: Robot) -> Self {
Self {
billing,
stripe,
robot,
}
}
pub fn router(self) -> Router {
let api = Arc::new(self);
Router::new()
.route("/identity", get(get_identity))
.route("/plans", get(list_plans))
.route("/plans/:id", get(get_plan))
.route("/tenants", get(list_tenants).post(create_tenant))
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
.route(
"/tenants/:pubkey/invoices/latest",
get(get_tenant_latest_invoice),
)
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
.route("/relays", get(list_relays).post(create_relay))
.route("/relays/:id", get(get_relay).put(update_relay))
.route("/relays/:id/members", get(list_relay_members))
.route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay))
.route("/relays/:id/reactivate", post(reactivate_relay))
.route("/invoices/:id", get(get_invoice))
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
.with_state(api)
}
// --- authorization helpers ----------------------------------------------
pub fn is_admin(&self, pubkey: &str) -> bool {
env::get().server_admin_pubkeys.iter().any(|a| a == pubkey)
}
pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError> {
if self.is_admin(authorized_pubkey) {
Ok(())
} else {
Err(forbidden("admin required"))
}
}
pub fn require_tenant(
&self,
authorized_pubkey: &str,
tenant_pubkey: &str,
) -> Result<(), ApiError> {
if authorized_pubkey == tenant_pubkey {
Ok(())
} else {
Err(forbidden("not authorized"))
}
}
pub fn require_admin_or_tenant(
&self,
authorized_pubkey: &str,
tenant_pubkey: &str,
) -> Result<(), ApiError> {
if self.is_admin(authorized_pubkey) || authorized_pubkey == tenant_pubkey {
Ok(())
} else {
Err(forbidden("not authorized"))
}
}
pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError> {
match query::get_tenant(pubkey).await {
Ok(Some(t)) => Ok(t),
Ok(None) => Err(not_found("tenant not found")),
Err(e) => Err(internal(e)),
}
}
pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError> {
match query::get_relay(id).await {
Ok(Some(r)) => Ok(r),
Ok(None) => Err(not_found("relay not found")),
Err(e) => Err(internal(e)),
}
}
// --- authentication -----------------------------------------------------
/// Decode the NIP-98 `Authorization` header and return the signer pubkey.
///
/// This is the intentional session-style variant of NIP-98 used by the
/// Caravel API: it validates signer identity plus host affinity, and does
/// not bind to exact request URL/method or maintain replay state. Any
/// failure surfaces as a 401 response.
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String, ApiError> {
self.decode_nip98_pubkey(headers).map_err(unauthorized)
}
fn decode_nip98_pubkey(&self, headers: &HeaderMap) -> Result<String> {
let auth = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow!("missing authorization header"))?;
ensure!(
auth.starts_with("Nostr "),
"authorization must use Nostr scheme"
);
let (_, b64) = auth
.split_once(' ')
.ok_or_else(|| anyhow!("malformed authorization header"))?;
let bytes = base64::engine::general_purpose::STANDARD.decode(b64)?;
let json = String::from_utf8(bytes)?;
let event = Event::from_json(json)?;
ensure!(event.kind == Kind::HttpAuth, "invalid nip98 kind");
event.verify()?;
let got_u = event
.tags
.iter()
.filter_map(|tag| {
let values = tag.as_slice();
(values.len() >= 2 && values[0] == "u").then(|| values[1].to_string())
})
.last()
.ok_or_else(|| anyhow!("missing u tag"))?;
ensure!(got_u == env::get().server_host, "authorization host mismatch");
Ok(event.pubkey.to_hex())
}
}
/// Axum extractor that authenticates a request via NIP-98 and yields the
/// signer's pubkey. Adding this parameter to a handler signature is the
/// decoration that enforces "must be authenticated"; handlers that omit it
/// remain anonymous.
pub struct AuthedPubkey(pub String);
#[async_trait]
impl FromRequestParts<Arc<Api>> for AuthedPubkey {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
api: &Arc<Api>,
) -> Result<Self, Self::Rejection> {
let pubkey = api.extract_auth_pubkey(&parts.headers)?;
Ok(Self(pubkey))
}
}