Add backend

This commit is contained in:
Jon Staab
2026-02-25 13:11:25 -08:00
parent d2ade19763
commit 42abde9dcd
11 changed files with 1104 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
ref
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0"
axum = "0.7"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "macros"] }
tokio = { version = "1.36", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
nostr-sdk = "0.39"
uuid = { version = "1.7", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.5", features = ["cors"] }
+96
View File
@@ -0,0 +1,96 @@
# Backend
Rust backend for the Nostr relay hosting platform. This service manages tenants, relays, invoices, and billing, and provisions relays by calling the zooid HTTP API. It authenticates requests using NIP-98.
## Tech Stack
- Rust (Edition 2024)
- Axum (HTTP server)
- SQLx + SQLite (persistence)
- Nostr SDK (NIP-98 verification)
## Layout
```
backend/
migrations/ # SQL migrations
src/
auth.rs # NIP-98 verification helper
config.rs # Env-based configuration
db.rs # SQLite pool + migrations
main.rs # Axum server entrypoint
models.rs # Data models
repo.rs # Data access layer
```
## Configuration
Environment variables:
| Variable | Description | Default |
|---|---|---|
| `DATABASE_URL` | SQLite database URL | `sqlite://data/hosting.db` |
| `HOST` | Bind host | `127.0.0.1` |
| `PORT` | Bind port | `3000` |
The database directory is created automatically if it doesnt exist.
## Database Schema
Created via `migrations/0001_init.sql`:
- `tenants`
- `relays`
- `invoices`
- `invoice_items`
## Running
```bash
cd backend
cargo run
```
Health check:
```
GET /healthz
```
## NIP-98 Authentication
NIP-98 verification is implemented in `auth.rs` using the Rust Nostr SDK. It verifies:
- Authorization header format
- Event signature and kind
- URL + HTTP method tags
- Timestamp validity
This is ready to be used by API routes.
## API Routes
Tenant routes (all require NIP-98 auth; pubkey is inferred from the token):
- `GET /tenant` — fetch (or create) tenant
- `GET /tenant/relays` — list tenant relays
- `POST /tenant/relays` — create relay
- `GET /tenant/relays/:id` — get relay
- `PUT /tenant/relays/:id` — update relay
- `DELETE /tenant/relays/:id` — deactivate relay
- `GET /tenant/invoices` — list invoices
Admin routes (all require NIP-98 auth; pubkey must be in `HOSTING_ADMIN_PUBKEYS`):
- `GET /admin/tenants` — list tenants
- `GET /admin/tenants/:pubkey` — tenant detail (includes relays)
- `PUT /admin/tenants/:pubkey` — update tenant status
- `GET /admin/relays` — list relays
- `GET /admin/relays/:id` — get relay
- `PUT /admin/relays/:id` — update relay
- `DELETE /admin/relays/:id` — deactivate relay
## Next Steps
- Add relay provisioning worker (zooid API calls)
- Add invoice generation and billing jobs
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS tenants (
pubkey TEXT PRIMARY KEY,
status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS relays (
id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
name TEXT NOT NULL,
subdomain TEXT NOT NULL,
schema TEXT NOT NULL,
description TEXT NOT NULL,
plan TEXT NOT NULL,
status TEXT NOT NULL,
FOREIGN KEY (tenant) REFERENCES tenants(pubkey)
);
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
amount INTEGER NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
invoice TEXT NOT NULL,
FOREIGN KEY (tenant) REFERENCES tenants(pubkey)
);
CREATE TABLE IF NOT EXISTS invoice_items (
id TEXT PRIMARY KEY,
invoice TEXT NOT NULL,
relay TEXT NOT NULL,
amount INTEGER NOT NULL,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
FOREIGN KEY (invoice) REFERENCES invoices(id),
FOREIGN KEY (relay) REFERENCES relays(id)
);
+547
View File
@@ -0,0 +1,547 @@
use std::sync::Arc;
use anyhow::{anyhow, Result};
use axum::{
extract::{Path, State},
http::{HeaderMap, Method, StatusCode, Uri},
response::{IntoResponse, Response},
routing::{delete, get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::verify_nip98;
use crate::models::{NewRelay, NewTenant, Relay, UpdateRelay};
use crate::repo::Repo;
#[derive(Clone)]
pub struct AppState {
pub repo: Repo,
pub admin_pubkeys: Arc<Vec<String>>,
}
pub fn router(state: AppState) -> Router {
let tenant_routes = Router::new()
.route("/tenant", get(get_tenant))
.route("/tenant/relays", get(list_tenant_relays).post(create_tenant_relay))
.route(
"/tenant/relays/:id",
get(get_tenant_relay).put(update_tenant_relay).delete(deactivate_tenant_relay),
)
.route("/tenant/invoices", get(list_tenant_invoices));
let admin_routes = Router::new()
.route("/admin/tenants", get(admin_list_tenants))
.route(
"/admin/tenants/:pubkey",
get(admin_get_tenant).put(admin_update_tenant_status),
)
.route("/admin/relays", get(admin_list_relays))
.route(
"/admin/relays/:id",
get(admin_get_relay).put(admin_update_relay).delete(admin_deactivate_relay),
);
Router::new()
.merge(tenant_routes)
.merge(admin_routes)
.with_state(state)
}
#[derive(Debug, Serialize)]
struct ApiError {
error: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(StatusCode::BAD_REQUEST, Json(self)).into_response()
}
}
fn unauthorized() -> Response {
(StatusCode::UNAUTHORIZED, Json(ApiError { error: "unauthorized".into() }))
.into_response()
}
fn forbidden() -> Response {
(StatusCode::FORBIDDEN, Json(ApiError { error: "forbidden".into() })).into_response()
}
fn not_found() -> Response {
(StatusCode::NOT_FOUND, Json(ApiError { error: "not found".into() })).into_response()
}
fn extract_auth_pubkey(headers: &HeaderMap, method: &Method, uri: &Uri) -> Result<String> {
let auth_header = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow!("missing authorization header"))?;
let host = headers
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let scheme = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http");
let path = uri.path_and_query().map(|v| v.as_str()).unwrap_or(uri.path());
let url = format!("{}://{}{}", scheme, host, path);
let pubkey = verify_nip98(auth_header, &url, method.as_str())?;
Ok(pubkey.to_string())
}
async fn get_tenant(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
match state.repo.get_tenant(&pubkey).await {
Ok(Some(tenant)) => (StatusCode::OK, Json(tenant)).into_response(),
Ok(None) => {
let tenant = NewTenant {
pubkey: pubkey.clone(),
status: "active".to_string(),
};
if state.repo.create_tenant(&tenant).await.is_ok() {
(StatusCode::OK, Json(tenant)).into_response()
} else {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to create tenant".into() }))
.into_response()
}
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load tenant".into() })).into_response(),
}
}
async fn list_tenant_relays(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
match state.repo.list_relays_by_tenant(&pubkey).await {
Ok(relays) => (StatusCode::OK, Json(relays)).into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relays".into() })).into_response(),
}
}
#[derive(Debug, Deserialize)]
struct CreateRelayRequest {
name: String,
subdomain: String,
description: String,
plan: String,
}
async fn create_tenant_relay(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Json(payload): Json<CreateRelayRequest>,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
let tenant = NewTenant {
pubkey: pubkey.clone(),
status: "active".to_string(),
};
if let Err(_) = state.repo.create_tenant_if_missing(&tenant).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to ensure tenant".into() }))
.into_response();
}
let relay = NewRelay {
id: Uuid::new_v4().to_string(),
tenant: pubkey.clone(),
name: payload.name,
subdomain: payload.subdomain.clone(),
schema: payload.subdomain.replace('-', "_"),
description: payload.description,
plan: payload.plan,
status: "pending".to_string(),
};
if let Err(_) = state.repo.create_relay(&relay).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to create relay".into() })).into_response();
}
spawn_provisioning_worker(relay.clone());
(StatusCode::CREATED, Json(relay)).into_response()
}
async fn get_tenant_relay(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Path(id): Path<String>,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
match state.repo.get_relay(&id).await {
Ok(Some(relay)) if relay.tenant == pubkey => (StatusCode::OK, Json(relay)).into_response(),
Ok(Some(_)) => forbidden(),
Ok(None) => not_found(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(),
}
}
#[derive(Debug, Deserialize)]
struct UpdateRelayRequest {
name: String,
subdomain: String,
description: String,
plan: String,
}
async fn update_tenant_relay(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Path(id): Path<String>,
Json(payload): Json<UpdateRelayRequest>,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
let existing = match state.repo.get_relay(&id).await {
Ok(Some(relay)) => relay,
Ok(None) => return not_found(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(),
};
if existing.tenant != pubkey {
return forbidden();
}
let updated = UpdateRelay {
id: existing.id,
name: payload.name,
subdomain: payload.subdomain.clone(),
schema: payload.subdomain.replace('-', "_"),
description: payload.description,
plan: payload.plan,
status: existing.status,
};
if let Err(_) = state.repo.update_relay(&updated).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to update relay".into() })).into_response();
}
(StatusCode::OK, Json(updated)).into_response()
}
async fn deactivate_tenant_relay(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Path(id): Path<String>,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
let existing = match state.repo.get_relay(&id).await {
Ok(Some(relay)) => relay,
Ok(None) => return not_found(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(),
};
if existing.tenant != pubkey {
return forbidden();
}
let updated = UpdateRelay {
id: existing.id,
name: existing.name,
subdomain: existing.subdomain.clone(),
schema: existing.subdomain.replace('-', "_"),
description: existing.description,
plan: existing.plan,
status: "deactivated".to_string(),
};
if let Err(_) = state.repo.update_relay(&updated).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to deactivate relay".into() })).into_response();
}
(StatusCode::OK, Json(updated)).into_response()
}
async fn list_tenant_invoices(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
match state.repo.list_invoices_by_tenant(&pubkey).await {
Ok(invoices) => (StatusCode::OK, Json(invoices)).into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load invoices".into() })).into_response(),
}
}
async fn admin_list_tenants(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
if !state.admin_pubkeys.contains(&pubkey) {
return forbidden();
}
match state.repo.list_tenants().await {
Ok(tenants) => (StatusCode::OK, Json(tenants)).into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load tenants".into() })).into_response(),
}
}
async fn admin_get_tenant(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Path(pubkey): Path<String>,
) -> Response {
let admin = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
if !state.admin_pubkeys.contains(&admin) {
return forbidden();
}
let tenant = match state.repo.get_tenant(&pubkey).await {
Ok(Some(tenant)) => tenant,
Ok(None) => return not_found(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load tenant".into() })).into_response(),
};
let relays = match state.repo.list_relays_by_tenant(&pubkey).await {
Ok(relays) => relays,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relays".into() })).into_response(),
};
#[derive(Serialize)]
struct TenantDetail {
tenant: crate::models::Tenant,
relays: Vec<Relay>,
}
(StatusCode::OK, Json(TenantDetail { tenant, relays })).into_response()
}
#[derive(Debug, Deserialize)]
struct UpdateTenantStatusRequest {
status: String,
}
async fn admin_update_tenant_status(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Path(pubkey): Path<String>,
Json(payload): Json<UpdateTenantStatusRequest>,
) -> Response {
let admin = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
if !state.admin_pubkeys.contains(&admin) {
return forbidden();
}
let tenant = match state.repo.get_tenant(&pubkey).await {
Ok(Some(tenant)) => tenant,
Ok(None) => return not_found(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load tenant".into() })).into_response(),
};
if let Err(_) = state
.repo
.update_tenant_status(&tenant.pubkey, &payload.status)
.await
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to update tenant".into() }))
.into_response();
}
let updated = NewTenant {
pubkey: tenant.pubkey,
status: payload.status,
};
(StatusCode::OK, Json(updated)).into_response()
}
async fn admin_list_relays(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
) -> Response {
let admin = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
if !state.admin_pubkeys.contains(&admin) {
return forbidden();
}
match state.repo.list_relays().await {
Ok(relays) => (StatusCode::OK, Json(relays)).into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relays".into() })).into_response(),
}
}
async fn admin_get_relay(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Path(id): Path<String>,
) -> Response {
let admin = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
if !state.admin_pubkeys.contains(&admin) {
return forbidden();
}
match state.repo.get_relay(&id).await {
Ok(Some(relay)) => (StatusCode::OK, Json(relay)).into_response(),
Ok(None) => not_found(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(),
}
}
async fn admin_update_relay(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Path(id): Path<String>,
Json(payload): Json<UpdateRelayRequest>,
) -> Response {
let admin = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
if !state.admin_pubkeys.contains(&admin) {
return forbidden();
}
let existing = match state.repo.get_relay(&id).await {
Ok(Some(relay)) => relay,
Ok(None) => return not_found(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(),
};
let updated = UpdateRelay {
id: existing.id,
name: payload.name,
subdomain: payload.subdomain.clone(),
schema: payload.subdomain.replace('-', "_"),
description: payload.description,
plan: payload.plan,
status: existing.status,
};
if let Err(_) = state.repo.update_relay(&updated).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to update relay".into() })).into_response();
}
(StatusCode::OK, Json(updated)).into_response()
}
async fn admin_deactivate_relay(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
Path(id): Path<String>,
) -> Response {
let admin = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
if !state.admin_pubkeys.contains(&admin) {
return forbidden();
}
let existing = match state.repo.get_relay(&id).await {
Ok(Some(relay)) => relay,
Ok(None) => return not_found(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(),
};
let updated = UpdateRelay {
id: existing.id,
name: existing.name,
subdomain: existing.subdomain.clone(),
schema: existing.subdomain.replace('-', "_"),
description: existing.description,
plan: existing.plan,
status: "deactivated".to_string(),
};
if let Err(_) = state.repo.update_relay(&updated).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to deactivate relay".into() })).into_response();
}
(StatusCode::OK, Json(updated)).into_response()
}
fn spawn_provisioning_worker(relay: NewRelay) {
tokio::spawn(async move {
tracing::info!(relay_id = relay.id, "provisioning worker started");
});
}
+15
View File
@@ -0,0 +1,15 @@
use anyhow::{anyhow, Result};
use std::str::FromStr;
use nostr_sdk::nostr::key::PublicKey;
use nostr_sdk::nostr::nips::nip98::{self, HttpMethod};
use nostr_sdk::nostr::types::time::Timestamp;
use nostr_sdk::nostr::types::url::Url;
pub fn verify_nip98(auth_header: &str, url: &str, method: &str) -> Result<PublicKey> {
let url = Url::parse(url).map_err(|e| anyhow!(e))?;
let method = HttpMethod::from_str(&method.to_uppercase()).map_err(|e| anyhow!(e))?;
let now = Timestamp::now();
nip98::verify_auth_header(auth_header, &url, method, now).map_err(|e| anyhow!(e))
}
+34
View File
@@ -0,0 +1,34 @@
use std::env;
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub host: String,
pub port: u16,
pub admin_pubkeys: Vec<String>,
}
impl Config {
pub fn from_env() -> Self {
let database_url =
env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://data/hosting.db".to_string());
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = env::var("PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3000);
let admin_pubkeys = env::var("HOSTING_ADMIN_PUBKEYS")
.unwrap_or_default()
.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect::<Vec<_>>();
Self {
database_url,
host,
port,
admin_pubkeys,
}
}
}
+15
View File
@@ -0,0 +1,15 @@
use anyhow::Result;
use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, SqlitePool};
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
pub async fn init_pool(database_url: &str) -> Result<SqlitePool> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
MIGRATOR.run(&pool).await?;
Ok(pool)
}
+63
View File
@@ -0,0 +1,63 @@
mod api;
mod auth;
mod config;
mod db;
mod models;
mod repo;
use std::net::SocketAddr;
use anyhow::Result;
use axum::{routing::get, Router};
use tokio::net::TcpListener;
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::config::Config;
use crate::db::init_pool;
use crate::repo::Repo;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer())
.init();
let config = Config::from_env();
ensure_sqlite_dir(&config.database_url)?;
let pool = init_pool(&config.database_url).await?;
let repo = Repo::new(pool);
let state = api::AppState {
repo,
admin_pubkeys: std::sync::Arc::new(config.admin_pubkeys.clone()),
};
let app = Router::new()
.merge(api::router(state))
.route("/healthz", get(healthz))
.layer(CorsLayer::permissive());
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = TcpListener::bind(addr).await?;
tracing::info!("listening on {}", addr);
axum::serve(listener, app).await?;
Ok(())
}
fn ensure_sqlite_dir(database_url: &str) -> Result<()> {
if let Some(path) = database_url.strip_prefix("sqlite://") {
if let Some(dir) = std::path::Path::new(path).parent() {
if !dir.as_os_str().is_empty() {
std::fs::create_dir_all(dir)?;
}
}
}
Ok(())
}
async fn healthz() -> &'static str {
"ok"
}
+88
View File
@@ -0,0 +1,88 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Tenant {
pub pubkey: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewTenant {
pub pubkey: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Relay {
pub id: String,
pub tenant: String,
pub name: String,
pub subdomain: String,
pub schema: String,
pub description: String,
pub plan: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewRelay {
pub id: String,
pub tenant: String,
pub name: String,
pub subdomain: String,
pub schema: String,
pub description: String,
pub plan: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateRelay {
pub id: String,
pub name: String,
pub subdomain: String,
pub schema: String,
pub description: String,
pub plan: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Invoice {
pub id: String,
pub tenant: String,
pub amount: i64,
pub status: String,
pub created_at: String,
pub invoice: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewInvoice {
pub id: String,
pub tenant: String,
pub amount: i64,
pub status: String,
pub created_at: String,
pub invoice: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct InvoiceItem {
pub id: String,
pub invoice: String,
pub relay: String,
pub amount: i64,
pub period_start: String,
pub period_end: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewInvoiceItem {
pub id: String,
pub invoice: String,
pub relay: String,
pub amount: i64,
pub period_start: String,
pub period_end: String,
}
+190
View File
@@ -0,0 +1,190 @@
use anyhow::Result;
use sqlx::{Sqlite, SqlitePool, Transaction};
use crate::models::{
Invoice, InvoiceItem, NewInvoice, NewInvoiceItem, NewRelay, NewTenant, Relay, Tenant, UpdateRelay,
};
#[derive(Clone)]
pub struct Repo {
pool: SqlitePool,
}
impl Repo {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub async fn update_tenant_status(&self, pubkey: &str, status: &str) -> Result<()> {
sqlx::query("UPDATE tenants SET status = ? WHERE pubkey = ?")
.bind(status)
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn create_tenant(&self, tenant: &NewTenant) -> Result<()> {
sqlx::query(
"INSERT INTO tenants (pubkey, status) VALUES (?, ?)",
)
.bind(&tenant.pubkey)
.bind(&tenant.status)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn create_tenant_if_missing(&self, tenant: &NewTenant) -> Result<()> {
sqlx::query(
"INSERT OR IGNORE INTO tenants (pubkey, status) VALUES (?, ?)",
)
.bind(&tenant.pubkey)
.bind(&tenant.status)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let tenant = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, status FROM tenants WHERE pubkey = ?",
)
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(tenant)
}
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
let tenants = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, status FROM tenants ORDER BY pubkey",
)
.fetch_all(&self.pool)
.await?;
Ok(tenants)
}
pub async fn create_relay(&self, relay: &NewRelay) -> Result<()> {
sqlx::query(
"INSERT INTO relays (id, tenant, name, subdomain, schema, description, plan, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant)
.bind(&relay.name)
.bind(&relay.subdomain)
.bind(&relay.schema)
.bind(&relay.description)
.bind(&relay.plan)
.bind(&relay.status)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn update_relay(&self, relay: &UpdateRelay) -> Result<()> {
sqlx::query(
"UPDATE relays SET name = ?, subdomain = ?, schema = ?, description = ?, plan = ?, status = ?
WHERE id = ?",
)
.bind(&relay.name)
.bind(&relay.subdomain)
.bind(&relay.schema)
.bind(&relay.description)
.bind(&relay.plan)
.bind(&relay.status)
.bind(&relay.id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let relay = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(relay)
}
pub async fn list_relays_by_tenant(&self, tenant: &str) -> Result<Vec<Relay>> {
let relays = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays WHERE tenant = ? ORDER BY name",
)
.bind(tenant)
.fetch_all(&self.pool)
.await?;
Ok(relays)
}
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let relays = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays ORDER BY name",
)
.fetch_all(&self.pool)
.await?;
Ok(relays)
}
pub async fn create_invoice_with_items(
&self,
invoice: &NewInvoice,
items: &[NewInvoiceItem],
) -> Result<()> {
let mut tx: Transaction<'_, Sqlite> = self.pool.begin().await?;
sqlx::query(
"INSERT INTO invoices (id, tenant, amount, status, created_at, invoice)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&invoice.id)
.bind(&invoice.tenant)
.bind(invoice.amount)
.bind(&invoice.status)
.bind(&invoice.created_at)
.bind(&invoice.invoice)
.execute(&mut *tx)
.await?;
for item in items {
sqlx::query(
"INSERT INTO invoice_items (id, invoice, relay, amount, period_start, period_end)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&item.id)
.bind(&item.invoice)
.bind(&item.relay)
.bind(item.amount)
.bind(&item.period_start)
.bind(&item.period_end)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn list_invoices_by_tenant(&self, tenant: &str) -> Result<Vec<Invoice>> {
let invoices = sqlx::query_as::<_, Invoice>(
"SELECT id, tenant, amount, status, created_at, invoice FROM invoices WHERE tenant = ? ORDER BY created_at DESC",
)
.bind(tenant)
.fetch_all(&self.pool)
.await?;
Ok(invoices)
}
pub async fn list_invoice_items(&self, invoice_id: &str) -> Result<Vec<InvoiceItem>> {
let items = sqlx::query_as::<_, InvoiceItem>(
"SELECT id, invoice, relay, amount, period_start, period_end FROM invoice_items WHERE invoice = ?",
)
.bind(invoice_id)
.fetch_all(&self.pool)
.await?;
Ok(items)
}
}