Kez/kez-chat/src/api.rs
Jason Tudisco 60aeaedbad feat(kez-chat): Web Push notifications + WhatsApp-style chat bubbles
Server (kez-chat/src/)
  - push.rs: VAPID (PEM/PKCS#8) auto-generated on first run;
    StoredSubscription store table; PushSender using
    IsahcWebPushClient; fanout drops 410/404 subs automatically.
    Push payload carries metadata only ({type,to,seq}) — never
    plaintext or ciphertext.
  - api.rs: GET /v1/push/vapid-public-key,
    POST /v1/push/subscribe/:handle, POST /v1/push/unsubscribe/:handle.
    Auth via X-KEZ-Auth: <ts>:<sig>, canonical message binds the
    endpoint URL so headers can't be replayed against other subs.
  - messages.rs: after broker.publish, fire-and-forget
    push.fanout for offline recipients.
  - config.rs: --vapid-key-path, --vapid-subject (env-backed).
  - main.rs: load_or_generate_vapid on startup.

Web client (kez-chat/web/src/)
  - vite.config.ts: switched vite-plugin-pwa to injectManifest mode.
  - sw.ts: custom service worker with workbox precache,
    NetworkOnly for /v1/*, NavigationRoute SPA fallback, push +
    notificationclick handlers (focus existing tab via postMessage,
    or open a new one).
  - lib/push.ts: enablePush / disablePush / isPushSubscribed +
    iOS PWA-install detection.
  - routes/Settings.svelte: "Background notifications (Web Push)"
    section with toggle and iOS Add-to-Home-Screen nudge.
  - main.ts: bridge from SW navigate message to svelte-spa-router
    via location.hash.

Chat UX (routes/Messages.svelte)
  - Bubbles now shrink-wrap to content with WhatsApp-style asymmetric
    corners and inline bottom-right timestamps. Old layout used
    nested block-level divs inside max-w-[78%], which stretched
    every bubble to full width regardless of content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 21:47:07 -06:00

512 lines
20 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 /.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<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(),
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::<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(),
)?;
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<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 /.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
// ─────────────────────────────────────────────────────────────────────────────
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"),
))
}