Fix compile errors

This commit is contained in:
Jon Staab
2026-02-25 16:10:24 -08:00
parent 643aa88e3f
commit 58e8c821d4
14 changed files with 247 additions and 27 deletions
+1
View File
@@ -1 +1,2 @@
ref
target
+1
View File
@@ -1,5 +1,6 @@
ref
node_modules
target
data
.env
**/.env
+13
View File
@@ -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",
+2 -1
View File
@@ -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"
+2 -2
View File
@@ -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,
}
+62 -4
View File
@@ -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<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 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<Event> {
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)?)
}
+42 -8
View File
@@ -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<nip47::Response> {
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,
+19
View File
@@ -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('/'))
}
+36 -3
View File
@@ -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<SqlitePool> {
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(())
}
+4 -4
View File
@@ -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());
}
}
+1 -1
View File
@@ -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 {
+17
View File
@@ -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`)
+1
View File
@@ -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 {
+46 -4
View File
@@ -21,10 +21,52 @@ export default function Login() {
})
return (
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 w-full max-w-md">
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">Log In</h1>
<div ref={container} />
<div class="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 flex items-center justify-center p-6">
<div class="w-full max-w-3xl">
<div class="relative overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(99,102,241,0.08),_transparent_45%)]" />
<div class="relative grid gap-8 p-8 md:grid-cols-[1.2fr_1fr] md:p-10">
<div class="space-y-6">
<div class="inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700">
Secure Nostr Login
</div>
<div>
<h1 class="text-3xl font-semibold text-gray-900 md:text-4xl">Welcome back</h1>
<p class="mt-3 text-sm leading-6 text-gray-600">
Connect your Nostr account to manage relay hosting, billing, and access in one place.
</p>
</div>
<div class="space-y-3 text-sm text-gray-600">
<div class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
<p>Own your identity with cryptographic sign-in.</p>
</div>
<div class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
<p>No passwords or email required.</p>
</div>
<div class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
<p>Fast access to your relays and invoices.</p>
</div>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Log in with Nostr</h2>
<p class="mt-2 text-xs text-gray-500">
New here? Create an account in seconds using your favorite Nostr signer.
</p>
<div class="mt-6" ref={container} />
<p class="mt-6 text-xs text-gray-500">
By continuing, you agree to use NIP-98 authentication for secure access.
</p>
</div>
</div>
</div>
<p class="mt-6 text-center text-xs text-gray-500">
Having trouble? Make sure your signer is unlocked and connected.
</p>
</div>
</div>
)