Kez/kez-chat/src/api.rs
Jason Tudisco 79e7e9536b feat(kez-chat): post-quantum v4 envelopes, heartbeat ratchet hardening, sig-server proxy
Crypto / protocol
- v4 envelope: X25519 + ML-KEM-768 hybrid (PQXDH-style HKDF over both
  secrets). Heartbeats now carry an ML-KEM key; senders emit v4 whenever
  the peer's heartbeat has one, v3 otherwise. Adds @noble/post-quantum.
- Heartbeats are signed by the KEZ identity and verified against the
  recipient's primary before use. Previously a spoofed nostr pubkey
  (from a re-wrapped replayed envelope) let an attacker substitute their
  own heartbeat and read messages.
- Heartbeat retention counts from supersession, never deletes the
  newest key, and GC runs after rotation — returning after a long
  offline stretch no longer destroys the key the backlog needs.
- Verified-heartbeat cache per peer: 5-min TTL (one lookup per file
  instead of per chunk) and anti-downgrade to identity ECDH.
- Always republish heartbeat on session start; rotate on the poll timer;
  atomic IDB updates; wipe heartbeats on "Forget this account".
- Dedupe inbound envelopes by sender_sig (replays / re-broadcasts).

Server
- Proxy /v1/sigchains/* to the internal sig-server and hand out the
  public https://<server>/v1/sigchains/... URL. Sigchain sync from the
  app was failing ("Load failed") against http://sig-server:7878.

App
- Relay settings apply to profile / NIP-65 publishing (republished on
  change); wss-only relays (ws:// for localhost); order-aware defaults.
- Clear chat also deletes attachments; retention changes apply
  immediately with a warning when shortening.
- Photos: HEIC -> JPEG and downscale to 2048px (iOS canvas limit, strips
  EXIF); snapshot the conversation at send time.
- Overlay a11y/Escape fixes, scroll-pin fix, svelte-check to 0 errors.
- Clear chat, image zoom, file-body fallback rendering, NIP-65 read
  relays in publish set, relay settings UI, forward-secrecy settings.

Docs
- README rewritten to explain the whole app; SECURITY.md updated for v4;
  heartbeat self-check (src/lib/heartbeat.selfcheck.ts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:32:08 -06:00

711 lines
29 KiB
Rust

//! 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 /v1/sigchains/:scheme/:id[/head] } proxied to the internal
//! POST /v1/sigchains/:scheme/:id/events } sig-server
//! 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,
/// Per-IP rate limiter for `POST /v1/messages`. See TODO.md Day 2 #15.
pub send_rate_limit: crate::rate_limit::RateLimiter,
}
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("/v1/push/subscriptions/:handle", get(push_list_subscriptions))
.route("/v1/sigchains/:scheme/:id", get(sigchain_proxy))
.route("/v1/sigchains/:scheme/:id/head", get(sigchain_proxy))
.route("/v1/sigchains/:scheme/:id/events", post(sigchain_proxy))
.route("/.well-known/webfinger", get(webfinger))
.route("/internal/nats/auth", post(nats_auth_callout));
router = if let Some(dir) = web_dir {
// Explicit no-cache for the files that gate everything else:
// • /sw.js — controls all client caching; stale = stuck on old build
// • /index.html — the SPA shell that loads hashed asset URLs
// • /manifest.webmanifest — drives PWA install behaviour
//
// Without these overrides Cloudflare cached our service worker
// for 4 hours and users got "ghost" deploys where the binary
// updated but the SW + bundle didn't, breaking notifications
// and session restore.
let dir_for_sw = dir.clone();
let dir_for_html = dir.clone();
let dir_for_manifest = dir.clone();
router
.route("/sw.js", get(move || serve_nocache(dir_for_sw.clone(), "sw.js")))
.route("/index.html", get(move || serve_nocache(dir_for_html.clone(), "index.html")))
.route(
"/manifest.webmanifest",
get(move || serve_nocache(dir_for_manifest.clone(), "manifest.webmanifest")),
)
.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<AppState>) -> Json<Value> {
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<String>,
}
async fn lookup(
State(state): State<AppState>,
Path(handle): Path<String>,
) -> Result<Json<HandleResponse>, ApiError> {
let record = state
.store
.lookup(&handle)
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(handle_response(&state.config, &record)))
}
/// Reverse lookup: `ed25519:<hex>` → 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<AppState>,
Path(primary): Path<String>,
) -> Result<Json<HandleResponse>, 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(),
// Public address, served by `sigchain_proxy` below. The
// sig-server itself (`sig_server_url`) is only reachable inside
// the deploy network, so it must never be handed to clients.
sigchain_url: format!("https://{}/v1/sigchains/{}/{}", config.server, scheme, id),
registered_at: record.registered_at.to_rfc3339(),
proofs: record
.proofs
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_default(),
}
}
// ─────────────────────────────────────────────────────────────────────────────
// POST /v1/register — claim a handle
// ─────────────────────────────────────────────────────────────────────────────
async fn register(
State(state): State<AppState>,
Json(req): Json<SignedRegistration>,
) -> Result<impl IntoResponse, ApiError> {
// 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<String>,
}
async fn set_proofs(
State(state): State<AppState>,
Path(handle): Path<String>,
headers: axum::http::HeaderMap,
Json(req): Json<SetProofsRequest>,
) -> Result<StatusCode, ApiError> {
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: <unix_ts>:<sig>, 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 <ts>:<sig>".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
// `<unix_ts>:<sig_hex>` 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<AppState>) -> Json<VapidKeyResponse> {
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 <ts>:<sig>".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<AppState>,
Path(handle): Path<String>,
headers: axum::http::HeaderMap,
Json(req): Json<PushSubscribeRequest>,
) -> Result<StatusCode, ApiError> {
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(),
)?;
let endpoint_for_log = req.endpoint.clone();
state
.store
.upsert_push_subscription(
&handle,
&crate::push::StoredSubscription {
endpoint: req.endpoint,
p256dh: req.p256dh,
auth: req.auth,
},
)
.await?;
// DEBUG, not INFO — the handle is sensitive enough that we
// don't want it in long-lived production logs. See TODO.md
// Day 1 #17. Diagnostic info (the push provider host) is fine.
tracing::debug!(
endpoint_host = %endpoint_for_log
.split_once("://").map(|(_, r)| r).unwrap_or(&endpoint_for_log)
.split(&['/', ':'][..]).next().unwrap_or("?"),
"push: subscription registered"
);
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct PushUnsubscribeRequest {
pub endpoint: String,
}
async fn push_unsubscribe(
State(state): State<AppState>,
Path(handle): Path<String>,
headers: axum::http::HeaderMap,
Json(req): Json<PushUnsubscribeRequest>,
) -> Result<StatusCode, ApiError> {
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 /v1/push/subscriptions/:handle — self-heal check
// ─────────────────────────────────────────────────────────────────────────────
//
// On every login (or session restore) the client calls this to verify
// the server still has the device's push subscription. Reasons it
// might be missing on the server:
// • Server dropped it after a 410 Gone from the push provider
// • Server lost its DB / was rebuilt
// • User browsed-data-cleared the device → has no local sub
// • Fresh device that never subscribed
//
// We return the LAST 16 chars of each endpoint URL — that's already
// unique enough across FCM/Mozilla/APNs and avoids leaking the full
// endpoint to any other party reading the response. The client
// matches its own browser PushSubscription.endpoint by suffix; if it
// has a local sub whose suffix isn't in the response, it re-registers.
#[derive(Debug, Serialize)]
struct PushSubscriptionsResponse {
/// Last-16-char tails of each registered endpoint URL — enough
/// for the client to identify whether *its* subscription is in
/// the set without us echoing the whole 200-char endpoint back.
endpoint_tails: Vec<String>,
}
async fn push_list_subscriptions(
State(state): State<AppState>,
Path(handle): Path<String>,
headers: axum::http::HeaderMap,
) -> Result<Json<PushSubscriptionsResponse>, ApiError> {
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()))?;
// Reuse the subscribe/unsubscribe canonical-msg shape with a
// dedicated verb so a captured "list" header can't be replayed
// as a subscribe (or vice versa).
let (ts_str, sig_hex) = auth
.split_once(':')
.ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be <ts>:<sig>".into()))?;
let ts: i64 = ts_str
.parse()
.map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?;
if (Utc::now().timestamp() - ts).abs() > 60 {
return Err(ApiError::Unauthorized("auth header is stale".into()));
}
let message = format!("GET\n/v1/push/subscriptions/{handle}\n{ts}");
kez_core::verify_ed25519_hex(record.primary.value(), message.as_bytes(), sig_hex)
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
let subs = state.store.list_push_subscriptions(&handle).await?;
let endpoint_tails: Vec<String> = subs
.iter()
.map(|s| {
let n = s.endpoint.len();
s.endpoint[n.saturating_sub(16)..].to_string()
})
.collect();
Ok(Json(PushSubscriptionsResponse { endpoint_tails }))
}
// ─────────────────────────────────────────────────────────────────────────────
// GET /.well-known/webfinger — fediverse-style discovery
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct WebfingerQuery {
resource: String,
}
async fn webfinger(
State(state): State<AppState>,
Query(q): Query<WebfingerQuery>,
) -> Result<impl IntoResponse, ApiError> {
// 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.<pubkey>.>` 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<AppState>,
Json(_body): Json<Value>,
) -> 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
// ─────────────────────────────────────────────────────────────────────────────
/// Serve a single file from `dir/filename` with explicit no-cache
/// headers so Cloudflare / browsers always revalidate. Used for the
/// SPA shell, manifest, and (most critically) the service worker.
///
/// Sends BOTH RFC 7234 `Cache-Control` AND legacy `Pragma`/`Expires`
/// — Cloudflare's edge respects the modern header, but the trio
/// together gets every intermediate proxy to do the right thing.
async fn serve_nocache(dir: std::path::PathBuf, filename: &'static str) -> axum::response::Response {
let path = dir.join(filename);
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(?path, error = %e, "serve_nocache: read failed");
return (StatusCode::NOT_FOUND, "not found").into_response();
}
};
let mime = match filename.rsplit_once('.').map(|(_, ext)| ext) {
Some("js") => "application/javascript; charset=utf-8",
Some("html") => "text/html; charset=utf-8",
Some("webmanifest") => "application/manifest+json; charset=utf-8",
_ => "application/octet-stream",
};
(
StatusCode::OK,
[
(header::CONTENT_TYPE, mime),
(
header::CACHE_CONTROL,
"no-store, no-cache, must-revalidate, max-age=0",
),
(header::PRAGMA, "no-cache"),
(header::EXPIRES, "0"),
],
bytes,
)
.into_response()
}
async fn placeholder_index(State(state): State<AppState>) -> Html<String> {
Html(format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>kez-chat — {server}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
max-width: 640px;
margin: 4rem auto;
padding: 0 1rem;
color: #222;
line-height: 1.5;
}}
h1 {{ margin-bottom: 0.5rem; }}
code {{ background: #f3f3f3; padding: 0.15rem 0.3rem; border-radius: 3px; }}
.api {{ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.9rem; }}
.api li {{ margin: 0.3rem 0; }}
footer {{ margin-top: 3rem; color: #888; font-size: 0.85rem; }}
</style>
</head>
<body>
<h1>kez-chat</h1>
<p>Home server for <code>username@{server}</code>.</p>
<p>The Svelte web app isn't built yet — this is the placeholder. The HTTP API
is up though:</p>
<ul class="api">
<li><code>GET /v1/healthz</code></li>
<li><code>GET /v1/u/&lt;handle&gt;</code></li>
<li><code>POST /v1/register</code></li>
<li><code>GET /.well-known/webfinger?resource=acct:user@{server}</code></li>
</ul>
<p>See <a href="https://git.ptud.biz/DukeInc/Kez">the project repo</a>
for the design doc and progress.</p>
<footer>kez-chat-server v{version} — server: <code>{server}</code></footer>
</body>
</html>"#,
server = state.config.server,
version = env!("CARGO_PKG_VERSION"),
))
}
/// Forward a sigchain request to the internal sig-server, same path and
/// body. The sig-server sits on the private deploy network (compose
/// service `sig-server`), so this is how browsers and external
/// verifiers reach it via the one public origin. It re-validates every
/// event itself (tag, seq, prev, signature), so no checks here.
async fn sigchain_proxy(
State(state): State<AppState>,
method: axum::http::Method,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> axum::response::Response {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("reqwest client")
});
let url = format!(
"{}{}",
state.config.sig_server_url.trim_end_matches('/'),
uri.path()
);
let mut req = client.request(method, &url).body(body);
if let Some(ct) = headers.get(header::CONTENT_TYPE) {
req = req.header(header::CONTENT_TYPE, ct);
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
match resp.bytes().await {
Ok(bytes) => {
let mut out = (status, bytes).into_response();
if let Some(ct) = ct {
out.headers_mut().insert(header::CONTENT_TYPE, ct);
}
out
}
Err(e) => sig_server_unavailable(e),
}
}
Err(e) => sig_server_unavailable(e),
}
}
fn sig_server_unavailable(e: reqwest::Error) -> axum::response::Response {
tracing::warn!(error = %e, "sig-server proxy failed");
(
StatusCode::BAD_GATEWAY,
Json(json!({"error": {"code": "sig_server_unavailable", "message": "sigchain store unreachable"}})),
)
.into_response()
}