//! HTTP API routes — all in one file for v0.1 since each route is small. //! //! GET / placeholder SPA (or web_dir) //! GET /v1/healthz liveness //! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints //! POST /v1/register claim a handle (signed body) //! GET /.well-known/webfinger?resource=... fediverse-style discovery //! POST /internal/nats/auth NATS auth callout (stub in v0.1) use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{StatusCode, header}; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tower_http::services::ServeDir; use crate::config::Config; use crate::error::ApiError; use crate::handles::validate_handle; use crate::registration::SignedRegistration; use crate::store::{HandleRecord, Store}; #[derive(Clone)] pub struct AppState { pub store: Store, pub config: Config, pub broker: crate::broker::Broker, pub vapid: crate::push::VapidKeys, pub push: crate::push::PushSender, } pub fn router(state: AppState) -> axum::Router { let web_dir = state.config.web_dir.clone(); // Build the router with all API routes first, attach the SPA fallback, // then apply state at the end (axum requires all routes to be added // before `with_state` is called). let mut router = axum::Router::new() .route("/v1/healthz", get(healthz)) .route("/v1/u/:handle", get(lookup)) .route("/v1/by-primary/:primary", get(lookup_by_primary)) .route("/v1/register", post(register)) .route("/v1/profile/:handle/proofs", axum::routing::put(set_proofs)) .route("/v1/messages", post(crate::messages::send_message)) .route("/v1/inbox/:handle", get(crate::messages::inbox)) .route("/v1/inbox/:handle/stream", get(crate::messages::stream_inbox)) .route("/v1/push/vapid-public-key", get(push_vapid_key)) .route("/v1/push/subscribe/:handle", post(push_subscribe)) .route("/v1/push/unsubscribe/:handle", post(push_unsubscribe)) .route("/.well-known/webfinger", get(webfinger)) .route("/internal/nats/auth", post(nats_auth_callout)); router = if let Some(dir) = web_dir { // Real SPA build dir provided; ServeDir handles index.html + assets. router.fallback_service(ServeDir::new(dir)) } else { // No SPA dir; serve a built-in placeholder page at `/`. router.route("/", get(placeholder_index)) }; router.with_state(state) } // ───────────────────────────────────────────────────────────────────────────── // healthz // ───────────────────────────────────────────────────────────────────────────── async fn healthz(State(state): State) -> Json { Json(json!({ "status": "ok", "server": state.config.server, "version": env!("CARGO_PKG_VERSION"), })) } // ───────────────────────────────────────────────────────────────────────────── // GET /v1/u/:handle — handle lookup // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize)] pub struct HandleResponse { pub handle: String, // bare local-part: "tudisco" pub fqhn: String, // fully qualified: "tudisco@kez.lat" pub primary: String, // e.g. "ed25519:abc..." pub sigchain_url: String, // where the sigchain lives pub registered_at: String, /// Claim subjects the user published for discovery (e.g. /// ["github:alice"]). Peers verify each independently. Empty if none. pub proofs: Vec, } async fn lookup( State(state): State, Path(handle): Path, ) -> Result, ApiError> { let record = state .store .lookup(&handle) .await? .ok_or(ApiError::NotFound)?; Ok(Json(handle_response(&state.config, &record))) } /// Reverse lookup: `ed25519:` → handle record. The Messages UI calls /// this when an inbound envelope's `from` field is a primary it hasn't /// seen before, so it can render "@alice" instead of "ed25519:abc…". async fn lookup_by_primary( State(state): State, Path(primary): Path, ) -> Result, ApiError> { let parsed = kez_core::Identity::parse(&primary) .map_err(|e| ApiError::BadRequest(format!("invalid primary: {e}")))?; let record = state .store .lookup_by_primary(&parsed) .await? .ok_or(ApiError::NotFound)?; Ok(Json(handle_response(&state.config, &record))) } fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse { let scheme = record.primary.scheme(); let id = record.primary.value(); HandleResponse { handle: record.handle.clone(), fqhn: format!("{}@{}", record.handle, config.server), primary: record.primary.to_string(), sigchain_url: format!( "{}/v1/sigchains/{}/{}", config.sig_server_url.trim_end_matches('/'), scheme, id ), registered_at: record.registered_at.to_rfc3339(), proofs: record .proofs .as_deref() .and_then(|s| serde_json::from_str::>(s).ok()) .unwrap_or_default(), } } // ───────────────────────────────────────────────────────────────────────────── // POST /v1/register — claim a handle // ───────────────────────────────────────────────────────────────────────────── async fn register( State(state): State, Json(req): Json, ) -> Result { // Format-level validation (envelope, signature) req.verify_format()?; // Semantic checks if req.payload.server != state.config.server { return Err(ApiError::BadRequest(format!( "registration server {:?} does not match this server {:?}", req.payload.server, state.config.server ))); } validate_handle(&req.payload.handle)?; req.check_timestamp(Utc::now())?; let record = HandleRecord { handle: req.payload.handle.clone(), primary: req.payload.primary.clone(), registered_at: Utc::now(), proofs: None, }; state.store.register(&record).await?; Ok(( StatusCode::CREATED, Json(handle_response(&state.config, &record)), )) } // ───────────────────────────────────────────────────────────────────────────── // PUT /v1/profile/:handle/proofs — publish your claim subjects (authed) // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] pub struct SetProofsRequest { /// Claim subjects, e.g. ["github:alice","dns:alice.com"]. pub proofs: Vec, } async fn set_proofs( State(state): State, Path(handle): Path, headers: axum::http::HeaderMap, Json(req): Json, ) -> Result { validate_handle(&handle) .map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?; let record = state.store.lookup(&handle).await?.ok_or(ApiError::NotFound)?; // Auth: X-KEZ-Auth: :, signed by the handle's primary // over the canonical request line. Same 60s skew window as inbox. let auth = headers .get("X-KEZ-Auth") .ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))? .to_str() .map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?; verify_profile_auth(auth, &handle, record.primary.value(), Utc::now().timestamp())?; // Cap to keep profiles small; reject absurd payloads. if req.proofs.len() > 64 { return Err(ApiError::BadRequest("too many proofs (max 64)".into())); } let json = serde_json::to_string(&req.proofs)?; state.store.set_proofs(&handle, &json).await?; Ok(StatusCode::NO_CONTENT) } /// Canonical message the proofs-setter signs. Distinct first line from /// the inbox/stream auth so signatures can't be cross-replayed. pub fn canonical_profile_message(handle: &str, ts: i64) -> String { format!("PUT\n/v1/profile/{handle}/proofs\n{ts}") } fn verify_profile_auth( auth: &str, handle: &str, pubkey_hex: &str, now_ts: i64, ) -> Result<(), ApiError> { let (ts_str, sig_hex) = auth .split_once(':') .ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be :".into()))?; let ts: i64 = ts_str .parse() .map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?; if (now_ts - ts).abs() > 60 { return Err(ApiError::Unauthorized("auth header is stale".into())); } let message = canonical_profile_message(handle, ts); kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex) .map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?; Ok(()) } // ───────────────────────────────────────────────────────────────────────────── // Web Push — VAPID key + subscribe/unsubscribe // ───────────────────────────────────────────────────────────────────────────── // // Auth model is identical to the proofs endpoint: the caller signs a // canonical request line with their primary Ed25519 key and puts // `:` in `X-KEZ-Auth`. The subscribe/unsubscribe // bodies stay tiny so push.js can hand us the SubscriptionJSON // straight from the browser. #[derive(Debug, Serialize)] struct VapidKeyResponse { /// uncompressed P-256 point, base64url-no-pad — passed straight to /// `PushManager.subscribe({applicationServerKey})`. key: String, } async fn push_vapid_key(State(state): State) -> Json { Json(VapidKeyResponse { key: state.vapid.public_b64url.clone(), }) } #[derive(Debug, Deserialize)] pub struct PushSubscribeRequest { pub endpoint: String, pub p256dh: String, pub auth: String, } /// Canonical message the caller signs for push subscribe/unsubscribe. /// Bound to the endpoint so a stolen header can't be replayed against /// a different subscription (e.g. attacker swapping in their own URL). pub fn canonical_push_message(verb: &str, handle: &str, endpoint: &str, ts: i64) -> String { format!("{verb}\n/v1/push/{verb}/{handle}\n{endpoint}\n{ts}") } fn verify_push_auth( auth: &str, verb: &str, handle: &str, endpoint: &str, pubkey_hex: &str, now_ts: i64, ) -> Result<(), ApiError> { let (ts_str, sig_hex) = auth .split_once(':') .ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be :".into()))?; let ts: i64 = ts_str .parse() .map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?; if (now_ts - ts).abs() > 60 { return Err(ApiError::Unauthorized("auth header is stale".into())); } let message = canonical_push_message(verb, handle, endpoint, ts); kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex) .map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?; Ok(()) } async fn push_subscribe( State(state): State, Path(handle): Path, headers: axum::http::HeaderMap, Json(req): Json, ) -> Result { validate_handle(&handle) .map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?; let record = state .store .lookup(&handle) .await? .ok_or(ApiError::NotFound)?; let auth = headers .get("X-KEZ-Auth") .ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))? .to_str() .map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?; verify_push_auth( auth, "subscribe", &handle, &req.endpoint, record.primary.value(), Utc::now().timestamp(), )?; state .store .upsert_push_subscription( &handle, &crate::push::StoredSubscription { endpoint: req.endpoint, p256dh: req.p256dh, auth: req.auth, }, ) .await?; Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Deserialize)] pub struct PushUnsubscribeRequest { pub endpoint: String, } async fn push_unsubscribe( State(state): State, Path(handle): Path, headers: axum::http::HeaderMap, Json(req): Json, ) -> Result { validate_handle(&handle) .map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?; let record = state .store .lookup(&handle) .await? .ok_or(ApiError::NotFound)?; let auth = headers .get("X-KEZ-Auth") .ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))? .to_str() .map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?; verify_push_auth( auth, "unsubscribe", &handle, &req.endpoint, record.primary.value(), Utc::now().timestamp(), )?; state.store.delete_push_subscription(&req.endpoint).await?; Ok(StatusCode::NO_CONTENT) } // ───────────────────────────────────────────────────────────────────────────── // GET /.well-known/webfinger — fediverse-style discovery // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] struct WebfingerQuery { resource: String, } async fn webfinger( State(state): State, Query(q): Query, ) -> Result { // Accept `acct:user@server` per RFC 7565. let resource = q .resource .strip_prefix("acct:") .ok_or_else(|| ApiError::BadRequest("resource must start with `acct:`".into()))?; let (handle, server) = resource .split_once('@') .ok_or_else(|| ApiError::BadRequest("resource must be `acct:user@server`".into()))?; if server != state.config.server { return Err(ApiError::NotFound); } let record = state.store.lookup(handle).await?.ok_or(ApiError::NotFound)?; let resp = handle_response(&state.config, &record); let body = json!({ "subject": format!("acct:{}", resp.fqhn), "links": [ { "rel": "https://kez.example/spec/v1/handle", "type": "application/json", "href": format!("https://{}/v1/u/{}", state.config.server, handle), }, { "rel": "https://kez.example/spec/v1/sigchain", "type": "application/jsonl", "href": resp.sigchain_url, } ] }); Ok(( StatusCode::OK, [(header::CONTENT_TYPE, "application/jrd+json")], Json(body), )) } // ───────────────────────────────────────────────────────────────────────────── // POST /internal/nats/auth — NATS auth callout (stub for v0.1) // ───────────────────────────────────────────────────────────────────────────── // // In v0.2 this will: parse the NATS auth request JWT, extract the // client's nkey, look it up in the handle registry, sign a response // JWT granting permissions to `kez.inbox..>` and reject if // not found. For now it returns 501 so misconfigured NATS deployments // fail loudly instead of silently allowing everyone. async fn nats_auth_callout( State(_state): State, Json(_body): Json, ) -> impl IntoResponse { ( StatusCode::NOT_IMPLEMENTED, Json(json!({ "error": { "code": "not_implemented", "message": "NATS auth callout will be wired up in v0.2" } })), ) } // ───────────────────────────────────────────────────────────────────────────── // Placeholder SPA — until we ship the real Svelte build // ───────────────────────────────────────────────────────────────────────────── async fn placeholder_index(State(state): State) -> Html { Html(format!( r#" kez-chat — {server}

kez-chat

Home server for username@{server}.

The Svelte web app isn't built yet — this is the placeholder. The HTTP API is up though:

  • GET /v1/healthz
  • GET /v1/u/<handle>
  • POST /v1/register
  • GET /.well-known/webfinger?resource=acct:user@{server}

See the project repo for the design doc and progress.

"#, server = state.config.server, version = env!("CARGO_PKG_VERSION"), )) }