From 58e8c821d4be3b5ac4255678d258ebd8268442b4 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 25 Feb 2026 16:10:24 -0800 Subject: [PATCH] Fix compile errors --- .fdignore | 1 + .gitignore | 1 + backend/Cargo.lock | 13 +++++++ backend/Cargo.toml | 3 +- backend/src/api.rs | 4 +-- backend/src/auth.rs | 66 +++++++++++++++++++++++++++++++++--- backend/src/billing.rs | 50 ++++++++++++++++++++++----- backend/src/config.rs | 19 +++++++++++ backend/src/db.rs | 39 +++++++++++++++++++-- backend/src/notifications.rs | 8 ++--- backend/src/provisioning.rs | 2 +- frontend/README.md | 17 ++++++++++ frontend/src/index.css | 1 + frontend/src/pages/Login.tsx | 50 ++++++++++++++++++++++++--- 14 files changed, 247 insertions(+), 27 deletions(-) diff --git a/.fdignore b/.fdignore index 6415016..31f3f95 100644 --- a/.fdignore +++ b/.fdignore @@ -1 +1,2 @@ ref +target diff --git a/.gitignore b/.gitignore index 9176080..f70a6aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ ref node_modules target +data .env **/.env diff --git a/backend/Cargo.lock b/backend/Cargo.lock index cca52a4..e376515 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -12,6 +12,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -191,6 +202,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "chrono", "dotenvy", "hex", @@ -1385,6 +1397,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d90b55eff1f0747d9e423972179672e1aacac3d3ccee4c1281147eaa90d6491e" dependencies = [ + "aes", "base64 0.22.1", "bech32", "bip39", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 123d5de..1cd8e03 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -12,7 +12,7 @@ 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" +nostr-sdk = { version = "0.39", features = ["nip04", "nip47", "nip59", "nip98"] } uuid = { version = "1.7", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } tower-http = { version = "0.5", features = ["cors"] } @@ -20,3 +20,4 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"] } rand = "0.8" hex = "0.4" dotenvy = "0.15.7" +base64 = "0.22" diff --git a/backend/src/api.rs b/backend/src/api.rs index 2e6b8e7..8c8e370 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -5,7 +5,7 @@ use axum::{ extract::{Path, State}, http::{HeaderMap, Method, StatusCode, Uri}, response::{IntoResponse, Response}, - routing::{delete, get, post, put}, + routing::{get, put}, Json, Router, }; use serde::{Deserialize, Serialize}; @@ -325,7 +325,7 @@ async fn list_tenant_invoices( } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] struct UpdateTenantBillingRequest { tenant_nwc_url: String, } diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 72b3f38..b4b83d2 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,15 +1,73 @@ use anyhow::{anyhow, Result}; +use base64::engine::general_purpose; +use base64::Engine; use std::str::FromStr; use nostr_sdk::nostr::key::PublicKey; -use nostr_sdk::nostr::nips::nip98::{self, HttpMethod}; +use nostr_sdk::nostr::nips::nip98::HttpMethod; use nostr_sdk::nostr::types::time::Timestamp; use nostr_sdk::nostr::types::url::Url; +use nostr_sdk::nostr::{Alphabet, Event, Kind, SingleLetterTag, TagKind, TagStandard}; +use nostr_sdk::JsonUtil; pub fn verify_nip98(auth_header: &str, url: &str, method: &str) -> Result { - let url = Url::parse(url).map_err(|e| anyhow!(e))?; - let method = HttpMethod::from_str(&method.to_uppercase()).map_err(|e| anyhow!(e))?; + let url = Url::parse(url)?; + let method = HttpMethod::from_str(&method.to_uppercase())?; let now = Timestamp::now(); - nip98::verify_auth_header(auth_header, &url, method, now, None).map_err(|e| anyhow!(e)) + let event = decode_auth_event(auth_header)?; + + if event.kind != Kind::HttpAuth { + return Err(anyhow!("authorization event kind mismatch")); + } + + let authorized_url = + match event + .tags + .find_standardized(TagKind::SingleLetter(SingleLetterTag::lowercase( + Alphabet::U, + ))) { + Some(TagStandard::AbsoluteURL(url)) => url, + _ => return Err(anyhow!("authorization header missing url tag")), + }; + + let authorized_method = match event.tags.find_standardized(TagKind::Method) { + Some(TagStandard::Method(method)) => method, + _ => return Err(anyhow!("authorization header missing method tag")), + }; + + if authorized_url != &url || authorized_method != &method { + return Err(anyhow!("authorization does not match request")); + } + + let created_at = event.created_at.as_u64(); + let now_secs = now.as_u64(); + if created_at > now_secs { + if created_at - now_secs > 30 { + return Err(anyhow!("authorization timestamp too far in future")); + } + } else if now_secs - created_at > 60 { + return Err(anyhow!("authorization timestamp too old")); + } + + event.verify()?; + Ok(event.pubkey) +} + +fn decode_auth_event(auth_header: &str) -> Result { + if auth_header.trim().is_empty() { + return Err(anyhow!("missing authorization header")); + } + + let (prefix, encoded) = auth_header + .split_once(' ') + .ok_or_else(|| anyhow!("malformed authorization header"))?; + + if prefix != "Nostr" || encoded.is_empty() { + return Err(anyhow!("malformed authorization header")); + } + + let decoded = general_purpose::STANDARD.decode(encoded)?; + let json = String::from_utf8(decoded)?; + Ok(Event::from_json(json)?) } diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 90c968c..c5f94e8 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -7,7 +7,8 @@ use crate::models::{Invoice, NewInvoice, NewInvoiceItem, Relay, Tenant}; use crate::notifications::Nip17Notifier; use crate::repo::Repo; -use nostr_sdk::nwc::prelude::*; +use nostr_sdk::nips::nip47::{self, MakeInvoiceRequest, NostrWalletConnectURI, PayInvoiceRequest}; +use nostr_sdk::{Client, Filter, Kind, Keys, Timestamp}; #[derive(Clone)] pub struct BillingService { @@ -140,20 +141,53 @@ impl BillingService { } let uri = NostrWalletConnectURI::parse(&self.platform_nwc_url)?; - let nwc = NWC::new(uri); - let request = MakeInvoiceRequest::new(amount as u64, "Relay hosting"); - let response = nwc.make_invoice(request).await?; - Ok(response.invoice) + let request = nip47::Request::make_invoice(MakeInvoiceRequest { + amount: (amount as u64) * 1_000, + description: Some("Relay hosting".to_string()), + description_hash: None, + expiry: None, + }); + let response = self.send_nwc_request(&uri, request).await?; + Ok(response.to_make_invoice()?.invoice) } async fn pay_invoice(&self, tenant_nwc_url: &str, invoice: &str) -> Result<()> { let uri = NostrWalletConnectURI::parse(tenant_nwc_url)?; - let nwc = NWC::new(uri); - let request = PayInvoiceRequest::new(invoice); - nwc.pay_invoice(request).await?; + let request = nip47::Request::pay_invoice(PayInvoiceRequest::new(invoice)); + self.send_nwc_request(&uri, request).await?.to_pay_invoice()?; Ok(()) } + async fn send_nwc_request( + &self, + uri: &NostrWalletConnectURI, + request: nip47::Request, + ) -> Result { + let app_keys = Keys::new(uri.secret.clone()); + let app_pubkey = app_keys.public_key(); + let client = Client::new(app_keys); + client.add_relay(uri.relay_url.clone()).await?; + client.connect().await; + + let started_at = Timestamp::now(); + let event = request.to_event(uri)?; + client.send_event(event).await?; + + let filter = Filter::new() + .kind(Kind::WalletConnectResponse) + .author(uri.public_key) + .pubkey(app_pubkey) + .since(started_at); + + let events = client.fetch_events(filter, Duration::from_secs(10)).await?; + let event = events + .into_iter() + .max_by_key(|event| event.created_at) + .ok_or_else(|| anyhow!("no NWC response"))?; + + Ok(nip47::Response::from_event(uri, &event)?) + } + async fn send_invoice_dm( &self, tenant: &Tenant, diff --git a/backend/src/config.rs b/backend/src/config.rs index a7d4678..9da1c8e 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -1,4 +1,5 @@ use std::env; +use std::path::Path; #[derive(Debug, Clone)] pub struct Config { @@ -21,6 +22,7 @@ impl Config { pub fn from_env() -> Self { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://data/caravel.db".to_string()); + let database_url = resolve_database_url(database_url); let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let port = env::var("PORT") .ok() @@ -71,3 +73,20 @@ impl Config { } } } + +fn resolve_database_url(database_url: String) -> String { + const PREFIX: &str = "sqlite://"; + if !database_url.starts_with(PREFIX) { + return database_url; + } + + let path = &database_url[PREFIX.len()..]; + if path.is_empty() || path.starts_with('/') || path == ":memory:" { + return database_url; + } + + let base = Path::new(env!("CARGO_MANIFEST_DIR")); + let absolute = base.join(path); + let normalized = absolute.to_string_lossy(); + format!("sqlite:///{}", normalized.trim_start_matches('/')) +} diff --git a/backend/src/db.rs b/backend/src/db.rs index 674f398..1e0f119 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,15 +1,48 @@ -use anyhow::Result; -use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, SqlitePool}; +use anyhow::{anyhow, Result}; +use sqlx::{migrate::Migrator, sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, SqlitePool}; +use std::str::FromStr; +use std::fs; +use std::path::Path; static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); pub async fn init_pool(database_url: &str) -> Result { + ensure_sqlite_directory(database_url)?; + let options = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(5) - .connect(database_url) + .connect_with(options) + .await?; + + sqlx::query("PRAGMA journal_mode = WAL;") + .execute(&pool) .await?; MIGRATOR.run(&pool).await?; Ok(pool) } + +fn ensure_sqlite_directory(database_url: &str) -> Result<()> { + const PREFIX: &str = "sqlite://"; + if !database_url.starts_with(PREFIX) { + return Ok(()); + } + + let path = &database_url[PREFIX.len()..]; + if path.is_empty() || path == ":memory:" { + return Ok(()); + } + + let path = if let Some(stripped) = path.strip_prefix('/') { + format!("/{}", stripped) + } else { + path.to_string() + }; + + let parent = Path::new(&path) + .parent() + .ok_or_else(|| anyhow!("invalid sqlite path"))?; + fs::create_dir_all(parent)?; + Ok(()) +} diff --git a/backend/src/notifications.rs b/backend/src/notifications.rs index f7dbf50..5c2b323 100644 --- a/backend/src/notifications.rs +++ b/backend/src/notifications.rs @@ -19,7 +19,7 @@ impl Nip17Notifier { return Err(anyhow!("PLATFORM_SECRET is required for NIP-17 notifications")); } - let keys = Keys::parse(platform_secret)?; + let keys = Keys::parse(&platform_secret)?; let indexer_client = Client::new(keys.clone()); for relay in &relays { @@ -71,15 +71,15 @@ impl Nip17Notifier { let filter = Filter::new().kind(Kind::Custom(10050)).author(pubkey); let events = self .indexer_client - .get_events_of(vec![filter], Some(Duration::from_secs(5))) + .fetch_events(filter, Duration::from_secs(5)) .await?; let mut relays = Vec::new(); if let Some(event) = events.into_iter().max_by_key(|event| event.created_at) { for tag in event.tags.iter() { - if let Some(first) = tag.as_vec().get(0) { + if let Some(first) = tag.as_slice().get(0) { if first == "relay" { - if let Some(value) = tag.as_vec().get(1) { + if let Some(value) = tag.as_slice().get(1) { relays.push(value.to_string()); } } diff --git a/backend/src/provisioning.rs b/backend/src/provisioning.rs index 93f4114..b535ad5 100644 --- a/backend/src/provisioning.rs +++ b/backend/src/provisioning.rs @@ -24,7 +24,7 @@ impl Provisioner { return Err(anyhow!("PLATFORM_SECRET is required")); } - let admin_keys = Keys::parse(admin_secret)?; + let admin_keys = Keys::parse(&admin_secret)?; let client = Client::new(); Ok(Self { diff --git a/frontend/README.md b/frontend/README.md index f4802ee..908fe50 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -76,3 +76,20 @@ Super admin dashboard: - `/admin/relays` — list relays - `/admin/relays/:id` — relay detail - `/admin/relays/:id/edit` — edit relay + +## Todos + +- [ ] Marketing page (`/`) with value props, features, and CTA +- [ ] Login page (`/login`) via nonboard +- [ ] Tenant dashboard auth via NIP-98 + - [ ] Relays list (`/relays`) with search/filter and add relay CTA + - [ ] Relay detail (`/relays/:id`) with edit + deactivate actions + - [ ] New relay form (`/relays/new`) with plan selection + invoice flow + - [ ] Relay edit form (`/relays/:id/edit`) + - [ ] Account page (`/account`) with status, invoices, and recurring billing toggle +- [ ] Super admin dashboard auth via `PLATFORM_ADMIN_PUBKEYS` + - [ ] Tenants list (`/admin/tenants`) + - [ ] Tenant detail (`/admin/tenants/:id`) with status + deactivate actions + - [ ] Relays list (`/admin/relays`) + - [ ] Relay detail (`/admin/relays/:id`) + - [ ] Relay edit form (`/admin/relays/:id/edit`) diff --git a/frontend/src/index.css b/frontend/src/index.css index c6126f9..352506d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "../node_modules/preline/variants.css"; @source "../node_modules/preline/dist/*.js"; +@import "nonboard/style.css"; /* Pointer cursor on buttons */ @layer base { diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 0928f5b..83cad8a 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -21,10 +21,52 @@ export default function Login() { }) return ( -
-
-

Log In

-
+
+
+
+
+
+
+
+ Secure Nostr Login +
+
+

Welcome back

+

+ Connect your Nostr account to manage relay hosting, billing, and access in one place. +

+
+
+
+ +

Own your identity with cryptographic sign-in.

+
+
+ +

No passwords or email required.

+
+
+ +

Fast access to your relays and invoices.

+
+
+
+ +
+

Log in with Nostr

+

+ New here? Create an account in seconds using your favorite Nostr signer. +

+
+

+ By continuing, you agree to use NIP-98 authentication for secure access. +

+
+
+
+

+ Having trouble? Make sure your signer is unlocked and connected. +

)