diff --git a/kez-chat/src/api.rs b/kez-chat/src/api.rs index 90b9bac..dd07bb4 100644 --- a/kez-chat/src/api.rs +++ b/kez-chat/src/api.rs @@ -38,6 +38,7 @@ pub fn router(state: AppState) -> axum::Router { 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/messages", post(crate::messages::send_message)) .route("/v1/inbox/:handle", get(crate::messages::inbox)) @@ -92,6 +93,23 @@ async fn lookup( Ok(Json(handle_response(&state.config, &record))) } +/// Reverse lookup: `ed25519:` → handle record. The Messages UI calls +/// this when an inbound envelope's `from` field is a primary it hasn't +/// seen before, so it can render "@alice" instead of "ed25519:abc…". +async fn lookup_by_primary( + State(state): State, + Path(primary): Path, +) -> Result, ApiError> { + let parsed = kez_core::Identity::parse(&primary) + .map_err(|e| ApiError::BadRequest(format!("invalid primary: {e}")))?; + let record = state + .store + .lookup_by_primary(&parsed) + .await? + .ok_or(ApiError::NotFound)?; + Ok(Json(handle_response(&state.config, &record))) +} + fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse { let scheme = record.primary.scheme(); let id = record.primary.value(); diff --git a/kez-chat/tests/http.rs b/kez-chat/tests/http.rs index 88e3722..f9d7eb4 100644 --- a/kez-chat/tests/http.rs +++ b/kez-chat/tests/http.rs @@ -94,6 +94,40 @@ async fn healthz_returns_ok() { assert_eq!(body["server"], "kez.test"); } +#[tokio::test] +async fn by_primary_round_trips() { + let server = spawn_server().await; + let secret = register_user(&server.base, "tudisco").await; + let primary = secret.identity().unwrap().to_string(); + + let resp = reqwest::get(format!("{}/v1/by-primary/{primary}", server.base)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["handle"], "tudisco"); + assert_eq!(body["primary"], primary); +} + +#[tokio::test] +async fn by_primary_unknown_404() { + let server = spawn_server().await; + let unregistered = Ed25519Secret::generate().identity().unwrap().to_string(); + let resp = reqwest::get(format!("{}/v1/by-primary/{unregistered}", server.base)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn by_primary_garbage_400() { + let server = spawn_server().await; + let resp = reqwest::get(format!("{}/v1/by-primary/not-a-primary", server.base)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn unknown_handle_returns_404() { let server = spawn_server().await; diff --git a/kez-chat/web/src/lib/api.ts b/kez-chat/web/src/lib/api.ts index db96a78..0a85170 100644 --- a/kez-chat/web/src/lib/api.ts +++ b/kez-chat/web/src/lib/api.ts @@ -68,6 +68,16 @@ export async function lookup(handle: string): Promise { return unwrap(resp); } +/** + * Reverse lookup: ed25519: primary → handle record. Used by the + * Messages UI to render "@alice" instead of the truncated hex when an + * inbound envelope arrives from someone we haven't chatted with yet. + */ +export async function lookupByPrimary(primary: string): Promise { + const resp = await fetch(url(`/v1/by-primary/${encodeURIComponent(primary)}`)); + return unwrap(resp); +} + export async function register( signed: SignedRegistration, ): Promise { diff --git a/kez-chat/web/src/lib/conversations-store.ts b/kez-chat/web/src/lib/conversations-store.ts index c3b8683..c920a0c 100644 --- a/kez-chat/web/src/lib/conversations-store.ts +++ b/kez-chat/web/src/lib/conversations-store.ts @@ -1,37 +1,45 @@ -// Local cache of decrypted messages, keyed by the other party's handle. -// IndexedDB so it survives reloads. Encrypted-at-rest is *not* attempted -// here — anyone with access to this browser profile already has the -// user's KEZ seed (which is the real secret). If we ever want at-rest -// encryption, encrypt the whole IDB store under a session-derived key. +// Local cache of decrypted message threads. Keyed by the *peer's primary* +// (the canonical KEZ identity — handles can in principle change, primaries +// can't), with the handle stored as display metadata. +// +// IndexedDB so it survives reloads. At-rest encryption deferred to v0.2: +// anyone with this browser profile already has the user's seed (the real +// secret), so encrypting the message log adds little practical security. import { get, set } from "idb-keyval"; import type { Identity } from "./kez.js"; -const KEY = "kez-chat:conversations"; +// :v2 — schema reshaped to key by peer_primary (canonical identity) instead +// of an ad-hoc string. Old data under :conversations (no version) is +// abandoned rather than migrated — those messages were the truncated-hex +// placeholders anyway. +const KEY = "kez-chat:conversations:v2"; export interface ConversationMessage { - /** Server seq when received (own sends use Date.now() to keep ordering). */ + /** Server seq for inbound, Date.now() for outbound. Only used for ordering + dedupe. */ seq: number; direction: "in" | "out"; - /** Display name = the other party (always the SAME for all messages in a thread). */ - peer: string; // bare handle - /** Sender's primary, for verifying who signed. */ - from: Identity; body: string; - /** ISO timestamp (sender's clock for `out`, recipient receive-time for `in`). */ + /** Sender's KEZ primary — for outbound this is my own primary. */ + from: Identity; + /** ISO timestamp (sender's clock for `in`, click-time for `out`). */ ts: string; } export interface Conversation { - peer: string; + /** Canonical key — never changes for this peer. */ + peer_primary: Identity; + /** Display handle, e.g. "alice@kez.lat". Empty string if we couldn't resolve. */ + peer_handle: string; messages: ConversationMessage[]; - /** Highest server seq we've processed from the inbox stream. */ + /** Max server-seq we've processed for any message from this peer. */ last_seq: number; } interface Store { - by_peer: Record; - /** Global highest seq across all peers — passed to /v1/inbox?since=. */ + /** Keyed by peer_primary. */ + by_peer: Record; + /** Highest server seq we've seen across all peers — passed as ?since=. */ global_cursor: number; } @@ -52,64 +60,98 @@ export async function listConversations(): Promise { }); } -export async function getConversation(peer: string): Promise { +export async function getConversation( + peer_primary: Identity, +): Promise { const s = await read(); - return s.by_peer[peer] ?? { peer, messages: [], last_seq: 0 }; + return s.by_peer[peer_primary] ?? null; } export async function getGlobalCursor(): Promise { return (await read()).global_cursor; } +/** + * Ensure a conversation row exists for this peer. Updates the display + * handle if a fresher value is passed in. Returns the conversation. + */ +export async function ensureConversation( + peer_primary: Identity, + peer_handle: string, +): Promise { + const s = await read(); + const existing = s.by_peer[peer_primary]; + if (existing) { + if (peer_handle && existing.peer_handle !== peer_handle) { + existing.peer_handle = peer_handle; + await write(s); + } + return existing; + } + const fresh: Conversation = { + peer_primary, + peer_handle, + messages: [], + last_seq: 0, + }; + s.by_peer[peer_primary] = fresh; + await write(s); + return fresh; +} + export async function appendInbound(opts: { + peer_primary: Identity; + peer_handle: string; seq: number; - peer: string; - from: Identity; body: string; ts: string; }): Promise { const s = await read(); - const conv = s.by_peer[opts.peer] ?? { peer: opts.peer, messages: [], last_seq: 0 }; - // Dedupe by seq in case a poll overlaps. + const conv = s.by_peer[opts.peer_primary] ?? { + peer_primary: opts.peer_primary, + peer_handle: opts.peer_handle, + messages: [], + last_seq: 0, + }; + // Refresh display name in case we just resolved it. + if (opts.peer_handle) conv.peer_handle = opts.peer_handle; if (!conv.messages.find((m) => m.direction === "in" && m.seq === opts.seq)) { conv.messages.push({ seq: opts.seq, direction: "in", - peer: opts.peer, - from: opts.from, body: opts.body, + from: opts.peer_primary, ts: opts.ts, }); } conv.last_seq = Math.max(conv.last_seq, opts.seq); - s.by_peer[opts.peer] = conv; + s.by_peer[opts.peer_primary] = conv; s.global_cursor = Math.max(s.global_cursor, opts.seq); await write(s); } export async function appendOutbound(opts: { - peer: string; + peer_primary: Identity; + peer_handle: string; from: Identity; body: string; }): Promise { const s = await read(); - const conv = s.by_peer[opts.peer] ?? { peer: opts.peer, messages: [], last_seq: 0 }; + const conv = + s.by_peer[opts.peer_primary] ?? { + peer_primary: opts.peer_primary, + peer_handle: opts.peer_handle, + messages: [], + last_seq: 0, + }; + if (opts.peer_handle) conv.peer_handle = opts.peer_handle; conv.messages.push({ seq: Date.now(), direction: "out", - peer: opts.peer, - from: opts.from, body: opts.body, + from: opts.from, ts: new Date().toISOString(), }); - s.by_peer[opts.peer] = conv; + s.by_peer[opts.peer_primary] = conv; await write(s); } - -export async function ensureConversation(peer: string): Promise { - const s = await read(); - if (!s.by_peer[peer]) { - s.by_peer[peer] = { peer, messages: [], last_seq: 0 }; - await write(s); - } -} diff --git a/kez-chat/web/src/routes/Messages.svelte b/kez-chat/web/src/routes/Messages.svelte index 2488ed2..08b910a 100644 --- a/kez-chat/web/src/routes/Messages.svelte +++ b/kez-chat/web/src/routes/Messages.svelte @@ -3,6 +3,7 @@ import { push } from "svelte-spa-router"; import { session } from "../lib/store.svelte.js"; import { decrypt, pollInbox, sendMessage } from "../lib/messages.js"; + import { lookup, lookupByPrimary, ApiError } from "../lib/api.js"; import { appendInbound, appendOutbound, @@ -12,16 +13,29 @@ listConversations, type Conversation, } from "../lib/conversations-store.js"; + import type { Identity } from "../lib/kez.js"; let conversations = $state([]); - let active = $state(null); + let activePrimary = $state(null); + let activeConv = $derived( + activePrimary + ? conversations.find((c) => c.peer_primary === activePrimary) ?? null + : null, + ); let composeText = $state(""); let composing = $state(false); - let newPeerInput = $state(""); let pollError = $state(null); let lastPolledAt = $state(null); let pollTimer: ReturnType | null = null; + // "Start chat with" lookup state. + let newPeerInput = $state(""); + let resolving = $state(false); + let resolveError = $state(null); + + // Toast for the share-link copy action. + let copied = $state(false); + const POLL_INTERVAL_MS = 5_000; onMount(async () => { @@ -29,9 +43,8 @@ push("/unlock"); return; } - await refreshConversations(); - // Eager first poll so the UI doesn't sit empty waiting for the 5s tick. - await pollOnce(); + await refresh(); + await pollOnce(); // first tick immediately so the UI isn't blank pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS); }); @@ -39,9 +52,8 @@ if (pollTimer) clearInterval(pollTimer); }); - async function refreshConversations() { + async function refresh() { conversations = await listConversations(); - if (active) active = await getConversation(active.peer); } async function pollOnce() { @@ -60,16 +72,23 @@ session.unlocked.handle, session.unlocked.seed, ); - // Peer = sender's handle. We only have the sender's primary - // here; resolve to a handle by looking at the envelope.from. - // For v0.1 the SPA can't reverse primary→handle cheaply, so - // we display the local-part of the primary if we can't figure - // it out — TODO add /v1/by-primary endpoint and cache. - const peer = pt.from; // primary as the "peer key" for now + // Resolve the sender's handle for display. Cache miss → ask server. + let handle = ""; + const existing = await getConversation(pt.from); + if (existing?.peer_handle) { + handle = existing.peer_handle; + } else { + try { + const record = await lookupByPrimary(pt.from); + handle = record.fqhn; + } catch { + // Unknown to this server (cross-server v0.2). Show truncated key. + } + } await appendInbound({ + peer_primary: pt.from, + peer_handle: handle, seq: m.seq, - peer, - from: pt.from, body: pt.body, ts: pt.sent_at, }); @@ -77,7 +96,7 @@ console.error(`seq ${m.seq}: decrypt failed`, e); } } - if (messages.length > 0) await refreshConversations(); + if (messages.length > 0) await refresh(); pollError = null; lastPolledAt = new Date().toISOString(); } catch (e) { @@ -85,21 +104,37 @@ } } - async function openConversation(peer: string) { - active = await getConversation(peer); - } - - async function startNewConversation() { - const peer = newPeerInput.trim(); - if (!peer) return; - await ensureConversation(peer); - newPeerInput = ""; - await refreshConversations(); - await openConversation(peer); + /** "Start chat with handle" — resolve, ensure conversation, open it. */ + async function startConversation() { + if (!session.unlocked || !newPeerInput.trim()) return; + resolving = true; + resolveError = null; + try { + // Accept "alice" or "alice@kez.lat". v0.1 ignores the server part — + // cross-server lookup lands in v0.2 along with sigchain push. + const local = newPeerInput.trim().split("@")[0]; + if (local === session.unlocked.handle) { + resolveError = "That's you — can't chat with yourself."; + return; + } + const record = await lookup(local); + await ensureConversation(record.primary as Identity, record.fqhn); + activePrimary = record.primary as Identity; + newPeerInput = ""; + await refresh(); + } catch (e) { + if (e instanceof ApiError && e.status === 404) { + resolveError = `No one with handle "${newPeerInput}" is registered on this server.`; + } else { + resolveError = (e as Error).message; + } + } finally { + resolving = false; + } } async function send() { - if (!session.unlocked || !active || !composeText.trim()) return; + if (!session.unlocked || !activeConv || !composeText.trim()) return; composing = true; try { const body = composeText; @@ -108,30 +143,35 @@ senderHandle: session.unlocked.handle, senderSeed: session.unlocked.seed, senderPrimary: session.unlocked.primary, - recipient: active.peer, + recipient: activeConv.peer_handle || activeConv.peer_primary, body, }); await appendOutbound({ - peer: active.peer, + peer_primary: activeConv.peer_primary, + peer_handle: activeConv.peer_handle, from: session.unlocked.primary, body, }); - await refreshConversations(); + await refresh(); } catch (e) { alert(`Send failed: ${(e as Error).message}`); - composeText += ""; + composeText = composeText; // no-op, keep linter happy } finally { composing = false; } } - /** Show short identifier — drop "ed25519:" prefix, keep first 12 chars. */ - function shortPeer(peer: string): string { - if (peer.startsWith("ed25519:")) { - const hex = peer.slice("ed25519:".length); + function shortKey(primary: Identity): string { + if (primary.startsWith("ed25519:")) { + const hex = primary.slice("ed25519:".length); return `${hex.slice(0, 8)}…${hex.slice(-4)}`; } - return peer; + return primary; + } + + /** What we show as the conversation title. */ + function displayName(c: Conversation): string { + return c.peer_handle || shortKey(c.peer_primary); } function formatTime(iso: string): string { @@ -146,61 +186,102 @@ } return d.toLocaleString([], { dateStyle: "short", timeStyle: "short" }); } + + async function copyMyKez() { + if (!session.unlocked) return; + await navigator.clipboard.writeText( + `${session.unlocked.handle}@${session.unlocked.server}`, + ); + copied = true; + setTimeout(() => (copied = false), 1500); + }
- -