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>
512 lines
20 KiB
Rust
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/<handle></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"),
|
|
))
|
|
}
|
|
|