Compare commits

...

2 Commits

Author SHA1 Message Date
f2970955dd Merge branch 'web-push' — Web Push + WhatsApp-style chat bubbles 2026-06-06 21:47:11 -06:00
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
17 changed files with 2014 additions and 80 deletions

1044
kez-chat/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,10 @@ thiserror = "2"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "sync", "signal"] }
tokio-stream = { version = "0.1", features = ["sync"] }
futures = "0.3"
web-push = "0.10"
base64 = "0.22"
p256 = { version = "0.13", features = ["pem"] }
rand = "0.8"
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@ -28,6 +28,8 @@ 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 {
@ -45,6 +47,9 @@ pub fn router(state: AppState) -> axum::Router {
.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));
@ -239,6 +244,145 @@ fn verify_profile_auth(
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
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -37,4 +37,21 @@ pub struct Config {
/// output). If unset, `/` serves a built-in placeholder page.
#[arg(long, env = "KEZ_CHAT_WEB_DIR")]
pub web_dir: Option<PathBuf>,
/// Where the Web Push VAPID private key is stored. Auto-generated
/// on first startup if the file doesn't exist (raw 32-byte P-256
/// scalar, base64-encoded). Public key is exposed at
/// /v1/push/vapid-public-key.
#[arg(long, env = "KEZ_CHAT_VAPID_KEY", default_value = "vapid-key.txt")]
pub vapid_key_path: PathBuf,
/// Subject for VAPID JWTs — typically a mailto: URL of the
/// operator. Push providers (FCM / Mozilla / APNs) use it to
/// contact the operator if subscriptions misbehave.
#[arg(
long,
env = "KEZ_CHAT_VAPID_SUBJECT",
default_value = "mailto:admin@kez.lat"
)]
pub vapid_subject: String,
}

View File

@ -7,6 +7,7 @@ pub mod config;
pub mod error;
pub mod handles;
pub mod messages;
pub mod push;
pub mod registration;
pub mod store;

View File

@ -26,10 +26,15 @@ async fn main() -> Result<()> {
);
let store = Store::open(&config.db)?;
let vapid =
kez_chat_server::push::load_or_generate_vapid(&config.vapid_key_path)?;
let push = kez_chat_server::push::PushSender::new(&vapid, &config.vapid_subject)?;
let state = AppState {
store,
config: config.clone(),
broker: kez_chat_server::broker::Broker::new(),
vapid,
push,
};
let app = router(state)

View File

@ -113,6 +113,22 @@ pub async fn send_message(
)
.await;
// Web Push fanout — fire-and-forget so the HTTP request still
// returns fast. The push payload is intentionally tiny and
// contains only metadata: the recipient's own client will pull
// the real (encrypted) envelope from the inbox/SSE on wake-up.
let push = state.push.clone();
let store = state.store.clone();
let recipient_handle = recipient.handle.clone();
let payload = serde_json::json!({
"type": "kez-chat/new-message",
"to": recipient_handle,
"seq": seq,
});
tokio::spawn(async move {
push.fanout(&store, &recipient_handle, &payload).await;
});
Ok(Json(SendMessageResponse { seq }))
}

305
kez-chat/src/push.rs Normal file
View File

@ -0,0 +1,305 @@
//! Web Push (RFC 8030 / RFC 8291 / VAPID RFC 8292) for kez-chat.
//!
//! Lets the chat-server fire notifications to the user's browser even
//! when the kez-chat PWA is fully closed. The browser registers a push
//! subscription with its push provider (FCM for Chrome/Edge, Mozilla
//! autopush for Firefox, Apple Push Notification Service for Safari);
//! we get back an endpoint URL + a pair of opaque keys (`p256dh`, `auth`).
//! When a new chat message lands, we POST a (tiny, possibly empty)
//! payload to the endpoint with a VAPID JWT proving the message is from
//! us. The push provider forwards it to the user's device; the device's
//! service worker wakes up briefly and calls `showNotification(...)`.
//!
//! Trust model:
//! - The push payload is opaque to the push provider (e.g. Google /
//! Apple) — we encrypt it under p256dh+auth as per RFC 8291.
//! - We deliberately send a *near-empty* payload: just the sender's
//! handle (which the recipient already knows about anyway) and the
//! message sequence. The actual ciphertext stays on our SSE/inbox
//! path, where the recipient's client pulls it and decrypts with the
//! KEZ E2E key. So even if a push provider went rogue, they wouldn't
//! see plaintext.
//! - 410 Gone from the provider → drop the subscription (the user
//! removed the app, the install expired, or the OS revoked it).
use std::path::Path;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
use chrono::Utc;
use p256::SecretKey;
use p256::elliptic_curve::sec1::ToEncodedPoint;
use p256::pkcs8::EncodePrivateKey;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
// Note: WebPushClient is a trait that provides the .send() method on
// IsahcWebPushClient — keep it in scope even though it looks "unused".
use web_push::{
ContentEncoding, IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys,
VapidSignatureBuilder, WebPushClient, WebPushError, WebPushMessageBuilder,
};
use crate::error::ApiError;
use crate::store::Store;
// ─────────────────────────────────────────────────────────────────────────────
// VAPID keys
// ─────────────────────────────────────────────────────────────────────────────
/// In-memory VAPID material. The private key is held as PEM bytes
/// because that's the form web-push's `VapidSignatureBuilder::from_pem`
/// consumes. We re-parse on every send (cheap) so we don't have to
/// juggle a long-lived signer object across an async boundary.
#[derive(Clone)]
pub struct VapidKeys {
/// PEM-encoded P-256 private key (PKCS#8).
pub private_pem: String,
/// Uncompressed P-256 point, 65 bytes (0x04 || X || Y), encoded
/// as base64url-no-pad — the form `applicationServerKey` expects
/// in `PushManager.subscribe()`.
pub public_b64url: String,
}
/// Load VAPID keys from `path`, generating a new pair on first run.
/// The on-disk file is a standard PKCS#8 PEM — `openssl ec -in <path>`
/// will read it.
pub fn load_or_generate_vapid<P: AsRef<Path>>(path: P) -> anyhow::Result<VapidKeys> {
let path = path.as_ref();
if path.exists() {
let private_pem = std::fs::read_to_string(path)?;
let public_b64url = derive_public_b64url(&private_pem)?;
tracing::info!(?path, "loaded VAPID key");
return Ok(VapidKeys {
private_pem,
public_b64url,
});
}
let secret = SecretKey::random(&mut rand::thread_rng());
let private_pem = secret
.to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
.map_err(|e| anyhow::anyhow!("pkcs8 encode: {e}"))?
.to_string();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, &private_pem)?;
// Best-effort tightening — failures are non-fatal on Windows.
let _ = std::fs::set_permissions(path, perms_0600());
let public_b64url = derive_public_b64url(&private_pem)?;
tracing::info!(?path, public_b64url = %public_b64url, "generated new VAPID key");
Ok(VapidKeys {
private_pem,
public_b64url,
})
}
fn derive_public_b64url(private_pem: &str) -> anyhow::Result<String> {
use p256::pkcs8::DecodePrivateKey;
let secret = SecretKey::from_pkcs8_pem(private_pem)
.map_err(|e| anyhow::anyhow!("pkcs8 decode: {e}"))?;
let public_point = secret.public_key().to_encoded_point(false); // uncompressed
Ok(B64URL.encode(public_point.as_bytes()))
}
#[cfg(unix)]
fn perms_0600() -> std::fs::Permissions {
use std::os::unix::fs::PermissionsExt;
std::fs::Permissions::from_mode(0o600)
}
#[cfg(not(unix))]
fn perms_0600() -> std::fs::Permissions {
// No-op stand-in; Windows ACLs are out of scope for v0.1.
use std::fs::Permissions;
Permissions::from(std::fs::Metadata::default().permissions())
}
// ─────────────────────────────────────────────────────────────────────────────
// Subscription record + store API
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredSubscription {
pub endpoint: String,
pub p256dh: String,
pub auth: String,
}
impl StoredSubscription {
fn to_subscription_info(&self) -> SubscriptionInfo {
SubscriptionInfo {
endpoint: self.endpoint.clone(),
keys: SubscriptionKeys {
p256dh: self.p256dh.clone(),
auth: self.auth.clone(),
},
}
}
}
impl Store {
/// Insert (or replace, by endpoint) a push subscription for `handle`.
pub async fn upsert_push_subscription(
&self,
handle: &str,
sub: &StoredSubscription,
) -> Result<(), ApiError> {
let conn = Store::inner_lock(self).await;
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO push_subscriptions (handle, endpoint, p256dh, auth, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(endpoint) DO UPDATE SET handle = ?1, p256dh = ?3, auth = ?4",
params![handle, sub.endpoint, sub.p256dh, sub.auth, now],
)?;
Ok(())
}
/// Drop one subscription by endpoint (used both for explicit
/// unsubscribe and for 410 Gone cleanup on send failure).
pub async fn delete_push_subscription(&self, endpoint: &str) -> Result<(), ApiError> {
let conn = Store::inner_lock(self).await;
conn.execute(
"DELETE FROM push_subscriptions WHERE endpoint = ?1",
params![endpoint],
)?;
Ok(())
}
/// Every active subscription for `handle`. Empty Vec is fine —
/// just means the user hasn't enabled push (yet) on any device.
pub async fn list_push_subscriptions(
&self,
handle: &str,
) -> Result<Vec<StoredSubscription>, ApiError> {
let conn = Store::inner_lock(self).await;
let mut stmt = conn.prepare(
"SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE handle = ?1",
)?;
let rows = stmt
.query_map(params![handle], |row| {
Ok(StoredSubscription {
endpoint: row.get(0)?,
p256dh: row.get(1)?,
auth: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
}
/// SQL fragment for the push_subscriptions table, run by store::init_schema.
pub const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
handle TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_push_handle
ON push_subscriptions (handle);
";
// ─────────────────────────────────────────────────────────────────────────────
// Sender
// ─────────────────────────────────────────────────────────────────────────────
/// Push notifier — clones cheaply (Arc-shared client + key material).
#[derive(Clone)]
pub struct PushSender {
inner: std::sync::Arc<PushInner>,
}
struct PushInner {
client: IsahcWebPushClient,
vapid_private_pem: String,
vapid_subject: String,
// Reserved for future provider tweaks without re-plumbing.
_lock: Mutex<()>,
}
impl PushSender {
pub fn new(vapid: &VapidKeys, vapid_subject: &str) -> anyhow::Result<Self> {
Ok(Self {
inner: std::sync::Arc::new(PushInner {
client: IsahcWebPushClient::new()?,
vapid_private_pem: vapid.private_pem.clone(),
vapid_subject: vapid_subject.to_owned(),
_lock: Mutex::new(()),
}),
})
}
/// Send a small payload to every subscription registered for
/// `recipient_handle`. Subscriptions that come back 410 Gone are
/// dropped from the store (the user removed the app, browser
/// rotated, etc.). All other errors are logged and ignored — push
/// is best-effort; the actual chat envelope is already in the
/// recipient's inbox.
pub async fn fanout(
&self,
store: &Store,
recipient_handle: &str,
payload: &serde_json::Value,
) {
let subs = match store.list_push_subscriptions(recipient_handle).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "push: list_subscriptions failed");
return;
}
};
if subs.is_empty() {
return;
}
let body = match serde_json::to_vec(payload) {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "push: payload serialize failed");
return;
}
};
for sub in subs {
if let Err(e) = self.send_one(&sub, &body).await {
match e {
WebPushError::EndpointNotValid | WebPushError::EndpointNotFound => {
// 410 Gone / 404 → subscription is dead; drop it.
tracing::info!(endpoint = %sub.endpoint, "push: dropping expired subscription");
let _ = store.delete_push_subscription(&sub.endpoint).await;
}
other => {
tracing::warn!(endpoint = %sub.endpoint, error = ?other, "push: send failed");
}
}
}
}
}
async fn send_one(
&self,
sub: &StoredSubscription,
body: &[u8],
) -> Result<(), WebPushError> {
let sub_info = sub.to_subscription_info();
let mut sig_builder = VapidSignatureBuilder::from_pem(
self.inner.vapid_private_pem.as_bytes(),
&sub_info,
)?;
sig_builder.add_claim("sub", self.inner.vapid_subject.as_str());
let signature = sig_builder.build()?;
let mut msg = WebPushMessageBuilder::new(&sub_info);
msg.set_payload(ContentEncoding::Aes128Gcm, body);
msg.set_vapid_signature(signature);
self.inner.client.send(msg.build()?).await
}
}

View File

@ -43,6 +43,12 @@ impl Store {
})
}
/// Crate-internal accessor to the connection mutex, for impl blocks
/// living in sibling modules (e.g. push.rs's subscription helpers).
pub(crate) async fn inner_lock(&self) -> tokio::sync::MutexGuard<'_, Connection> {
self.inner.lock().await
}
/// Reserve a handle for a primary key. Fails with Conflict if the
/// handle is already taken, or if this primary key has already
/// registered a (different) handle.
@ -162,7 +168,12 @@ fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> {
);
CREATE INDEX IF NOT EXISTS idx_messages_recipient
ON messages (recipient_handle, seq);",
)
)?;
// Web Push subscription store. Schema kept in push.rs so it lives
// next to the feature it backs; spliced in here so the table is
// created on first run.
conn.execute_batch(crate::push::SCHEMA)
}
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -30,7 +30,10 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vite-plugin-pwa": "^1.3.0"
"vite-plugin-pwa": "^1.3.0",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1"
}
},
"node_modules/@apideck/better-ajv-errors": {

View File

@ -32,6 +32,9 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vite-plugin-pwa": "^1.3.0"
"vite-plugin-pwa": "^1.3.0",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1"
}
}

View File

@ -0,0 +1,223 @@
// Web Push subscription helpers — wraps the browser's PushManager and
// the chat-server's /v1/push/* endpoints into a small surface the
// Settings page can call.
//
// Flow:
//
// enablePush(handle, seed)
// 1. fetch /v1/push/vapid-public-key
// 2. browser PushManager.subscribe({applicationServerKey})
// 3. sign + POST the subscription JSON to /v1/push/subscribe/:handle
//
// disablePush(handle, seed)
// 1. SW.pushManager.getSubscription() → unsubscribe locally
// 2. sign + POST /v1/push/unsubscribe/:handle with the endpoint URL
//
// All auth uses the same X-KEZ-Auth: <ts>:<sig_hex> scheme as the rest
// of the API (see kez-chat/src/api.rs: canonical_push_message).
import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import { ApiError } from "./api.js";
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
function url(path: string): string {
return `${API_BASE}${path}`;
}
/**
* Web Push is only usable if the browser supports it AND the page is
* served from a secure context (HTTPS or localhost). On iOS Safari it
* additionally requires the site to be installed as a PWA Safari
* exposes the PushManager API only after add-to-home-screen.
*/
export function pushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
/** Current permission state — does NOT prompt the user. */
export function pushPermission(): NotificationPermission | "unsupported" {
if (!pushSupported()) return "unsupported";
return Notification.permission;
}
/**
* VAPID applicationServerKey arrives as base64url-no-pad; PushManager
* wants a Uint8Array. Pad + replace, then decode.
*/
function decodeB64Url(b64: string): Uint8Array {
const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
const bin = atob(padded + pad);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/** Encode an ArrayBuffer as base64url-no-pad — server expects this form. */
function encodeB64Url(buf: ArrayBuffer | null): string {
if (!buf) return "";
const bytes = new Uint8Array(buf);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function vapidPublicKey(): Promise<string> {
const resp = await fetch(url("/v1/push/vapid-public-key"));
if (!resp.ok) {
throw new ApiError(resp.status, `vapid key fetch → ${resp.status}`);
}
const body = (await resp.json()) as { key: string };
return body.key;
}
interface SubscriptionPayload {
endpoint: string;
p256dh: string;
auth: string;
}
function subscriptionPayload(sub: PushSubscription): SubscriptionPayload {
return {
endpoint: sub.endpoint,
p256dh: encodeB64Url(sub.getKey("p256dh")),
auth: encodeB64Url(sub.getKey("auth")),
};
}
function signPushAuth(
verb: "subscribe" | "unsubscribe",
handle: string,
endpoint: string,
seed: Uint8Array,
): string {
const ts = Math.floor(Date.now() / 1000);
const msg = `${verb}\n/v1/push/${verb}/${handle}\n${endpoint}\n${ts}`;
const sig = ed25519.sign(new TextEncoder().encode(msg), seed);
return `${ts}:${bytesToHex(sig)}`;
}
/**
* Enable Web Push for `handle`. Prompts for permission if needed,
* subscribes to the browser's push provider, and registers the
* subscription with the chat-server.
*
* Returns `false` if the user declined permission. Throws on
* network/server failures.
*/
export async function enablePush(
handle: string,
seed: Uint8Array,
): Promise<boolean> {
if (!pushSupported()) {
throw new Error("Web Push not supported in this browser");
}
// iOS Safari requires the PWA to be installed (display-mode: standalone).
// We don't *block* on this — the subscribe() call will surface a clearer
// error than we could — but settings can use this as a hint.
const perm = await Notification.requestPermission();
if (perm !== "granted") return false;
const reg = await navigator.serviceWorker.ready;
const appServerKey = decodeB64Url(await vapidPublicKey());
// If a stale subscription exists (e.g. for a different handle), drop
// it first — we want exactly one active subscription per browser.
const existing = await reg.pushManager.getSubscription();
if (existing) {
await existing.unsubscribe().catch(() => {});
}
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
// The TS DOM lib types `applicationServerKey` as ArrayBufferView<ArrayBuffer>,
// but our Uint8Array is over the generic ArrayBufferLike. The runtime is
// happy with either — cast to keep tsc quiet.
applicationServerKey: appServerKey as unknown as BufferSource,
});
const payload = subscriptionPayload(sub);
const authHeader = signPushAuth("subscribe", handle, payload.endpoint, seed);
const resp = await fetch(url(`/v1/push/subscribe/${encodeURIComponent(handle)}`), {
method: "POST",
headers: {
"content-type": "application/json",
"X-KEZ-Auth": authHeader,
},
body: JSON.stringify(payload),
});
if (!resp.ok) {
// Best-effort: drop the local subscription too so the user can retry
// cleanly. Otherwise the browser stays subscribed but the server
// doesn't know about it and notifications never arrive.
await sub.unsubscribe().catch(() => {});
throw new ApiError(resp.status, `push subscribe → ${resp.status}`);
}
return true;
}
/**
* Disable Web Push for `handle`. Tells both the browser and the
* chat-server to forget the subscription. Idempotent.
*/
export async function disablePush(
handle: string,
seed: Uint8Array,
): Promise<void> {
if (!pushSupported()) return;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (!sub) return;
// Tell the server first so we still have a valid auth-able endpoint
// string. If the server call fails we still unsubscribe locally —
// 410 cleanup on the server side will catch the stale row on next
// fanout attempt anyway.
const authHeader = signPushAuth("unsubscribe", handle, sub.endpoint, seed);
await fetch(url(`/v1/push/unsubscribe/${encodeURIComponent(handle)}`), {
method: "POST",
headers: {
"content-type": "application/json",
"X-KEZ-Auth": authHeader,
},
body: JSON.stringify({ endpoint: sub.endpoint }),
}).catch(() => {});
await sub.unsubscribe().catch(() => {});
}
/**
* Is this browser currently subscribed to push? Used by Settings to
* render the toggle in the right state.
*/
export async function isPushSubscribed(): Promise<boolean> {
if (!pushSupported()) return false;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
return sub !== null;
}
/**
* iOS PWA detection Safari only exposes PushManager once installed
* to the home screen. We use this to render a "Tap Share Add to Home
* Screen" callout instead of a broken toggle.
*/
export function isStandalonePwa(): boolean {
if (typeof window === "undefined") return false;
// iOS Safari's legacy `standalone` bool, plus the modern matchMedia.
const legacy = (window.navigator as unknown as { standalone?: boolean }).standalone === true;
const modern = window.matchMedia?.("(display-mode: standalone)").matches ?? false;
return legacy || modern;
}
export function isIos(): boolean {
if (typeof navigator === "undefined") return false;
return /iPhone|iPad|iPod/.test(navigator.userAgent);
}

View File

@ -26,6 +26,20 @@ if ("serviceWorker" in navigator) {
refreshing = true;
window.location.reload();
});
// Bridge from the service worker's `notificationclick` handler: when
// the user taps a push notification and we found an existing tab to
// focus, the SW posts `{type: "kez-chat/navigate", to: <hash-path>}`
// and we route the SPA there. Hash routing means we just set
// `location.hash` — svelte-spa-router picks it up.
navigator.serviceWorker.addEventListener("message", (event) => {
const data = event.data as { type?: string; to?: string } | undefined;
if (data?.type === "kez-chat/navigate" && typeof data.to === "string") {
// svelte-spa-router uses #/path — convert plain path to that form.
const target = data.to.startsWith("#") ? data.to : `#${data.to}`;
window.location.hash = target;
}
});
}
export default app;

View File

@ -435,19 +435,51 @@
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-1.5" bind:this={scrollEl}>
<div class="flex-1 overflow-y-auto px-3 py-4 space-y-1" bind:this={scrollEl}>
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
{@const boost = emojiOnlyBoost(m.body)}
{@const out = m.direction === "out"}
<div class={`max-w-[78%] ${out ? "ml-auto" : ""}`}>
<!--
WhatsApp-style row: the row is a flex container that pins
the bubble to the left (incoming) or right (outgoing). The
bubble itself is sized to its CONTENT, capped at 75% of the
row — that's why a one-word "Boo" stays a small chip while
a long message wraps. Timestamp lives inside the bubble,
bottom-right, the way iMessage / WhatsApp / Telegram do it.
-->
<div class={`flex ${out ? "justify-end" : "justify-start"}`}>
{#if boost}
<div class={`whitespace-pre-wrap break-words leading-none py-1 ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${out ? "text-right" : ""}`}>{m.body}</div>
<div class={`whitespace-pre-wrap break-words leading-none py-1 max-w-[75%] ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${out ? "text-right" : ""}`}>{m.body}</div>
{:else}
<div
class={`px-3 py-2 text-sm whitespace-pre-wrap break-words ${out ? "bg-accent text-accent-contrast rounded-lg rounded-br-sm" : "bg-bubble-recv text-text border border-border rounded-lg rounded-bl-sm"}`}
>{m.body}</div>
class={[
"relative max-w-[75%] px-3 pt-1.5 pb-1 text-sm shadow-sm",
// Bubble corners: rounded everywhere except the
// "tail" corner (matches WhatsApp shape).
out
? "bg-accent text-accent-contrast rounded-2xl rounded-br-md"
: "bg-bubble-recv text-text rounded-2xl rounded-bl-md",
].join(" ")}
>
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
<!--
Inline timestamp. `float-right` + a leading non-
breaking space pulls the time onto the same baseline
as the last line of text when there's room, and
drops to its own line when the text wraps right up
to it. Lower opacity so it doesn't compete.
-->
<span
class={[
"float-right ml-2 mt-1 text-[10px] leading-none select-none",
out ? "text-accent-contrast/70" : "text-text-muted",
].join(" ")}
aria-hidden="true"
>{formatTime(m.ts)}</span>
<!-- Screen-reader timestamp (the visual one is decorative). -->
<span class="sr-only">{formatTime(m.ts)}</span>
</div>
{/if}
<p class={`mt-0.5 text-[10px] text-text-muted ${out ? "text-right" : ""}`}>{formatTime(m.ts)}</p>
</div>
{/each}
{#if activeConv.messages.length === 0}

View File

@ -17,6 +17,14 @@
requestNotificationsPermission,
fireTestNotification,
} from "../lib/inbox-service.svelte.js";
import {
pushSupported,
enablePush,
disablePush,
isPushSubscribed,
isStandalonePwa,
isIos,
} from "../lib/push.js";
import { theme, type ThemeChoice } from "../lib/theme.svelte.js";
import { onboarding } from "../lib/onboarding.svelte.js";
@ -42,6 +50,13 @@
let notifPerm = $state<NotificationPermission | "unsupported">("default");
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
let webPushOk = $state(false); // browser supports it at all
let webPushOn = $state(false); // currently subscribed
let webPushBusy = $state(false);
let webPushError = $state<string | null>(null);
let pwaInstalled = $state(false);
let ios = $state(false);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
@ -50,6 +65,12 @@
await refreshBiometricStatus();
notifSupported = notificationsSupported();
notifPerm = notificationsPermission();
webPushOk = pushSupported();
pwaInstalled = isStandalonePwa();
ios = isIos();
if (webPushOk) {
webPushOn = await isPushSubscribed();
}
});
async function refreshBiometricStatus() {
@ -93,6 +114,26 @@
setTimeout(() => (testNotifResult = null), 5_000);
}
async function toggleWebPush() {
if (!session.unlocked) return;
webPushBusy = true;
webPushError = null;
try {
if (webPushOn) {
await disablePush(session.unlocked.handle, session.unlocked.seed);
webPushOn = false;
} else {
const ok = await enablePush(session.unlocked.handle, session.unlocked.seed);
webPushOn = ok;
if (!ok) webPushError = "Permission denied.";
}
} catch (e) {
webPushError = (e as Error).message;
} finally {
webPushBusy = false;
}
}
async function showSeed() {
if (!session.unlocked) return;
const phrase = session.unlocked.phrase;
@ -230,6 +271,57 @@
{/if}
</section>
<!-- Web Push notifications (background) -->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-2">
Background notifications (Web Push)
</h2>
<p class="text-sm text-text-secondary">
Get pinged even when kez-chat is fully closed. The push only
carries metadata — message content is decrypted locally when you
open the app.
</p>
{#if !webPushOk}
{#if ios && !pwaInstalled}
<p class="mt-3 text-sm text-warning">
iOS only supports Web Push for installed PWAs. Tap the
<strong>Share</strong> button in Safari and choose
<strong>Add to Home Screen</strong>, then reopen kez-chat from
the home-screen icon to enable this.
</p>
{:else}
<p class="mt-3 text-sm text-text-muted italic">
Not supported in this browser.
</p>
{/if}
{:else if webPushOn}
<div class="mt-3 space-y-2">
<p class="text-sm" style="color:var(--color-verified)">
✓ Subscribed on this device.
</p>
<button
class="px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
disabled={webPushBusy}
onclick={toggleWebPush}
>
{webPushBusy ? "…" : "Disable"}
</button>
</div>
{:else}
<button
class="mt-3 px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={webPushBusy}
onclick={toggleWebPush}
>
{webPushBusy ? "Subscribing…" : "Enable on this device"}
</button>
{/if}
{#if webPushError}
<p class="mt-2 text-xs text-danger">{webPushError}</p>
{/if}
</section>
<!-- Account / About -->
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>

121
kez-chat/web/src/sw.ts Normal file
View File

@ -0,0 +1,121 @@
// Custom service worker for kez-chat.
//
// Built by vite-plugin-pwa in `injectManifest` mode. We own this file
// so we can handle two events the auto-generated SW doesn't:
//
// 1. `push` — wake on a Web Push notification from the
// chat-server and call `showNotification`.
// 2. `notificationclick` — focus an existing tab or open the SPA.
//
// Caching strategy is hand-rolled (rather than the long list of
// runtimeCaching rules the generateSW config used) because the surface
// is small: precache the SPA shell, network-only for /v1/* API calls,
// SPA fallback for navigation requests.
/// <reference lib="webworker" />
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
declare const self: ServiceWorkerGlobalScope;
// vite-plugin-pwa injects the precache manifest at build time.
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// SPA fallback: same-origin navigation requests (other than /v1/*)
// resolve to index.html so deep links (e.g. /chats/alice) work offline.
const navigationHandler = createHandlerBoundToURL("index.html");
const navigationRoute = new NavigationRoute(navigationHandler, {
denylist: [/^\/v1\//, /^\/internal\//, /^\/\.well-known\//],
});
registerRoute(navigationRoute);
// Never cache API responses — they're authenticated + dynamic.
registerRoute(({ url }) => url.pathname.startsWith("/v1/"), new NetworkOnly());
// Skip waiting + claim control of open pages — paired with the
// controllerchange reload in main.ts, deploys land on the first refresh
// instead of the second.
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
// ─── Web Push ───────────────────────────────────────────────────────────────
//
// Server fanout sends a tiny JSON payload (see kez-chat/src/messages.rs):
// { "type": "kez-chat/new-message", "to": "<handle>", "seq": <number> }
//
// We DON'T put plaintext (or even ciphertext) in the push payload — the
// recipient's client pulls the real envelope from /v1/inbox on wake-up
// and decrypts there. This keeps the push provider (Apple/Google/Mozilla)
// from ever seeing message content even theoretically.
interface PushPayload {
type?: string;
to?: string;
seq?: number;
}
self.addEventListener("push", (event: PushEvent) => {
let data: PushPayload = {};
if (event.data) {
try {
data = event.data.json() as PushPayload;
} catch {
// Some providers send a wake-up "" payload — fall through and
// show a generic notification so the user knows to open the app.
}
}
const title = "New kez-chat message";
const body =
data.to !== undefined
? `You have a new message in @${data.to}`
: "Open kez-chat to view it.";
// `renotify` is widely supported but isn't in the baseline TS DOM lib;
// build the options as a plain object and cast.
const options = {
body,
icon: "/pwa-192x192.png",
badge: "/pwa-64x64.png",
// Group same-conversation pings — iOS especially gets spammy
// otherwise. `renotify` lets the next one still vibrate.
tag: data.to ? `kez-chat:${data.to}` : "kez-chat:new",
renotify: true,
data,
} as NotificationOptions;
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event: NotificationEvent) => {
event.notification.close();
const data = event.notification.data as PushPayload | undefined;
const targetUrl = data?.to ? `/chats/${encodeURIComponent(data.to)}` : "/chats";
event.waitUntil(
(async () => {
const clientList = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of clientList) {
const url = new URL(client.url);
if (url.origin === self.location.origin) {
// Found an already-open kez-chat tab — focus it and ask the
// SPA to navigate; cheaper than spawning a fresh window.
await client.focus();
client.postMessage({ type: "kez-chat/navigate", to: targetUrl });
return;
}
}
await self.clients.openWindow(targetUrl);
})(),
);
});

View File

@ -42,6 +42,18 @@ export default defineConfig({
// stuck on an old build.
registerType: "autoUpdate",
injectRegister: "auto",
// We need a custom Service Worker so we can handle `push` and
// `notificationclick` events. `injectManifest` keeps the workbox
// precache list autogenerated but lets us own the SW source.
strategies: "injectManifest",
srcDir: "src",
filename: "sw.ts",
injectManifest: {
// zstd wasm is ~350 KB; raise the per-file cap so the precache
// doesn't silently skip it.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
globPatterns: ["**/*.{js,css,html,svg,png,ico,wasm}"],
},
manifest: {
name: "kez-chat",
short_name: "kez-chat",
@ -65,32 +77,9 @@ export default defineConfig({
},
],
},
workbox: {
// Activate new SW immediately + take control of existing pages
// without waiting for them to close. Paired with the
// controllerchange reload in main.ts, this means deploys land
// on the first refresh instead of the second.
skipWaiting: true,
clientsClaim: true,
// Precache the SPA shell. Chat data is fetched live from /v1/*
// and we DON'T want it cached — see runtimeCaching below.
globPatterns: ["**/*.{js,css,html,svg,png,ico,wasm}"],
// zstd wasm is ~350 KB; raise the per-file cap.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
// Same-origin navigation requests fall back to the SPA shell so
// /messages, /claims, etc. work after a refresh while offline.
navigateFallback: "index.html",
navigateFallbackDenylist: [/^\/v1\//, /^\/internal\//, /^\/\.well-known\//],
runtimeCaching: [
{
// Never cache API responses — they're authenticated + dynamic.
urlPattern: /\/v1\//,
handler: "NetworkOnly",
},
],
navigationPreload: true,
cleanupOutdatedCaches: true,
},
// No `workbox: {...}` here — that key only applies in
// `generateSW` mode. In injectManifest mode, the SW source itself
// (src/sw.ts) handles caching strategies + push events.
devOptions: {
enabled: false,
},