mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-20 00:38:41 +02:00
Wire the settings overlay into the live config path: - AppState.config: Config -> Arc<ArcSwap<Config>> (hot-swappable effective config) + config_base: Arc<Config> (immutable env-only). New AppState::cfg() accessor; every config read goes through it (state.cfg().field), so a runtime-safe operator edit is seen on the next request. One Config type, no parallel path. - Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build every client/worker/router from the EFFECTIVE config. A persisted overlay that no longer validates is ignored (boot env-only + loud log), never bricks. Env-only when ADMIN_KEY_INTEGRITY_SECRET is unset. - Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a reference outlives the statement; integrity_secret returns owned String. - Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep). Behavior-preserving: with no operator_settings rows, effective == env config. cargo check --all-targets clean; lib tests 256/0.
193 lines
7.9 KiB
Rust
193 lines
7.9 KiB
Rust
//! NIP-05 directory hosting: serves `/.well-known/nostr.json`.
|
|
//!
|
|
//! This is the directory half of NIP-05. It resolves a Smirk `username` to the
|
|
//! Nostr pubkey the user has linked (the `nostr_pubkey` column, set via
|
|
//! `POST /api/v1/auth/nostr/link`), and pairs with the wallet-side resolver
|
|
//! (`@smirk/core` `resolveNip05`) so Smirk users become findable by any Nostr
|
|
//! client.
|
|
//!
|
|
//! Behavior is intentionally enumeration-safe and non-failing for the public
|
|
//! lookup surface:
|
|
//!
|
|
//! * Usernames are stored lowercased (see `users::set_username`), so the `?name=`
|
|
//! query is lowercased before lookup.
|
|
//! * An unknown name, an unlinked user, or a missing `name` parameter all return
|
|
//! the SAME empty document (`{"names":{},"relays":{}}`) — never a 404 and never
|
|
//! an error — so the endpoint does not disclose which usernames exist.
|
|
//! * Foreign error detail (sqlx) is the only thing routed to `AppError`; the
|
|
//! client-facing failure messages are literals.
|
|
//!
|
|
//! This module mounts at the SERVER ROOT, not under `/api/v1`: NIP-05 mandates
|
|
//! the well-known path `/.well-known/nostr.json`. The application is expected to
|
|
//! `merge` [`routes`] into the root router (alongside the `/api/v1`-nested
|
|
//! routers).
|
|
//!
|
|
//! Spec: <https://github.com/nostr-protocol/nips/blob/master/05.md>
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
extract::{Query, State},
|
|
routing::get,
|
|
Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::instrument;
|
|
|
|
use crate::error::AppError;
|
|
use crate::AppState;
|
|
|
|
// ── constants ─────────────────────────────────────────────────────────────────
|
|
|
|
/// Default relay hints advertised in the well-known `relays` map.
|
|
///
|
|
/// Public relays shared with other wallets so cross-wallet delivery works before
|
|
/// any per-user relay list is configured. NIP-05 lets a server advertise relay
|
|
/// hints per pubkey in the `relays` object; we serve the same default set for
|
|
/// every resolved name.
|
|
///
|
|
/// TODO(phase-2): make configurable and serve per-user lists (NIP-65 kind 10002
|
|
/// / NIP-17 inbox relays) when private-message delivery lands.
|
|
const DEFAULT_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://nos.lol"];
|
|
|
|
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// Query for `GET /.well-known/nostr.json`.
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct WellKnownQuery {
|
|
/// The local part to resolve (NIP-05 `?name=`). Absent or unknown resolves to
|
|
/// an empty document rather than an error.
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
/// NIP-05 well-known document.
|
|
///
|
|
/// * `names` maps each resolved local part to its x-only Nostr pubkey (hex).
|
|
/// * `relays` maps each returned pubkey to a list of recommended relay URLs.
|
|
///
|
|
/// Both maps are empty when the name is unknown, unlinked, or omitted.
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct WellKnownResponse {
|
|
/// `{ local_part: nostr_pubkey_hex }`. Empty when nothing resolved.
|
|
pub names: HashMap<String, String>,
|
|
/// `{ nostr_pubkey_hex: [relay_url, ...] }`. Empty when nothing resolved.
|
|
pub relays: HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
// ── GET /.well-known/nostr.json ────────────────────────────────────────────────
|
|
|
|
/// NIP-05 resolution for Smirk usernames.
|
|
///
|
|
/// Looks up the user by lowercased `?name=` and, if they have a linked
|
|
/// `nostr_pubkey`, returns it in `names` (with the default relay hints under
|
|
/// `relays`). An unknown name, an unlinked user, or an absent `name` parameter
|
|
/// all return an empty document — this endpoint never errors on the lookup path
|
|
/// and never discloses which usernames exist.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/.well-known/nostr.json",
|
|
params(
|
|
("name" = Option<String>, Query, description = "NIP-05 local part to resolve")
|
|
),
|
|
responses(
|
|
(status = 200, description = "NIP-05 directory document (empty when unresolved)", body = WellKnownResponse)
|
|
),
|
|
tag = "nip05"
|
|
)]
|
|
#[instrument(skip(state))]
|
|
pub async fn well_known_nostr(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(q): Query<WellKnownQuery>,
|
|
) -> Result<Json<WellKnownResponse>, AppError> {
|
|
let mut names = HashMap::new();
|
|
let mut relays = HashMap::new();
|
|
|
|
if let Some(raw) = q.name {
|
|
// Usernames are stored lowercased (see users::set_username).
|
|
let name = raw.to_lowercase();
|
|
if let Some(user) = state.db.get_user_by_username(&name).await? {
|
|
if let Some(pubkey) = user.nostr_pubkey {
|
|
relays.insert(pubkey.clone(), relay_hints(&state.cfg()));
|
|
names.insert(name, pubkey);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(Json(WellKnownResponse { names, relays }))
|
|
}
|
|
|
|
/// Relay hints advertised for a resolved pubkey: this instance's own relay (the
|
|
/// user's DM inbox) FIRST when enabled, then the public interop relays so
|
|
/// cross-wallet (e.g. Goblin) delivery still works before any per-user list.
|
|
/// Per-user lists (NIP-65 kind 10002 / NIP-17 inbox relays) remain future work.
|
|
fn relay_hints(config: &crate::config::Config) -> Vec<String> {
|
|
let mut hints = Vec::new();
|
|
let r = &config.messaging.relay;
|
|
if r.enabled && !r.advertised_url.trim().is_empty() {
|
|
hints.push(r.advertised_url.clone());
|
|
}
|
|
for d in DEFAULT_RELAYS {
|
|
if !hints.iter().any(|h| h == d) {
|
|
hints.push((*d).to_string());
|
|
}
|
|
}
|
|
hints
|
|
}
|
|
|
|
// ── router ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// NIP-05 route, mounted at the SERVER ROOT (NOT under `/api/v1`). NIP-05
|
|
/// mandates the well-known path verbatim, so the application is expected to
|
|
/// `merge` this into the root router rather than nest it under `/api/v1`.
|
|
pub fn routes() -> Router<Arc<AppState>> {
|
|
Router::new().route("/.well-known/nostr.json", get(well_known_nostr))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The query field is optional: `name` round-trips when present and defaults
|
|
/// to `None` when absent.
|
|
#[test]
|
|
fn well_known_query_wire_shape() {
|
|
let with_name: WellKnownQuery =
|
|
serde_json::from_str(r#"{"name":"alice"}"#).expect("name parses");
|
|
assert_eq!(with_name.name.as_deref(), Some("alice"));
|
|
|
|
let empty: WellKnownQuery = serde_json::from_str("{}").expect("empty parses");
|
|
assert!(empty.name.is_none());
|
|
}
|
|
|
|
/// Default relay hints are well-formed `wss://` URLs.
|
|
#[test]
|
|
fn default_relays_are_wss() {
|
|
assert!(!DEFAULT_RELAYS.is_empty());
|
|
assert!(DEFAULT_RELAYS.iter().all(|r| r.starts_with("wss://")));
|
|
}
|
|
|
|
/// The response serializes to the exact NIP-05 wire shape, and an empty
|
|
/// document is `{"names":{},"relays":{}}` (never `null`).
|
|
#[test]
|
|
fn response_serializes_to_nip05_shape() {
|
|
let empty = WellKnownResponse {
|
|
names: HashMap::new(),
|
|
relays: HashMap::new(),
|
|
};
|
|
let json = serde_json::to_value(&empty).unwrap();
|
|
assert_eq!(json, serde_json::json!({ "names": {}, "relays": {} }));
|
|
|
|
let mut names = HashMap::new();
|
|
names.insert("alice".to_string(), "abc123".to_string());
|
|
let mut relays = HashMap::new();
|
|
relays.insert(
|
|
"abc123".to_string(),
|
|
vec!["wss://relay.damus.io".to_string()],
|
|
);
|
|
let json = serde_json::to_value(&WellKnownResponse { names, relays }).unwrap();
|
|
assert_eq!(json["names"]["alice"], "abc123");
|
|
assert_eq!(json["relays"]["abc123"][0], "wss://relay.damus.io");
|
|
}
|
|
}
|