// Thin HTTP client for kez-chat-server. Same calls a native CLI would // make — the SPA dogfoods the API surface. import { ed25519 } from "@noble/curves/ed25519"; import { bytesToHex } from "@noble/hashes/utils"; import type { SignedRegistration } from "./kez.js"; export interface HandleResponse { handle: string; fqhn: string; primary: string; sigchain_url: string; registered_at: string; /** Claim subjects the user published for discovery, e.g. ["github:alice"]. */ proofs: string[]; } export interface ApiErrorBody { error: { code: string; message: string }; } export class ApiError extends Error { status: number; code?: string; constructor(status: number, message: string, code?: string) { super(message); this.status = status; this.code = code; } } /** * In dev, requests go to whatever Vite's proxy points at (see vite.config.ts). * In prod, the SPA is served by the same chat-server, so relative URLs work. * Override via `VITE_API_BASE` env var if you want to point at a different * server during dev (e.g. https://kez.lat). */ const API_BASE = import.meta.env.VITE_API_BASE ?? ""; function url(path: string): string { return `${API_BASE}${path}`; } async function unwrap(resp: Response): Promise { if (!resp.ok) { let body: ApiErrorBody | undefined; try { body = await resp.json(); } catch { /* ignore — body wasn't JSON */ } throw new ApiError( resp.status, body?.error?.message ?? `HTTP ${resp.status}`, body?.error?.code, ); } return resp.json() as Promise; } export async function healthz(): Promise<{ status: string; server: string; version: string; }> { const resp = await fetch(url("/v1/healthz")); return unwrap(resp); } export async function lookup(handle: string): Promise { const resp = await fetch(url(`/v1/u/${encodeURIComponent(handle)}`)); 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); } /** * Publish your verified claim subjects to your server profile so peers * can discover (and independently verify) them — drives the verified * badge in chat. Authed with X-KEZ-Auth signed by your primary. */ export async function setProofs( handle: string, seed: Uint8Array, subjects: string[], ): Promise { const ts = Math.floor(Date.now() / 1000); const msg = `PUT\n/v1/profile/${handle}/proofs\n${ts}`; const sig = ed25519.sign(new TextEncoder().encode(msg), seed); const resp = await fetch(url(`/v1/profile/${encodeURIComponent(handle)}/proofs`), { method: "PUT", headers: { "content-type": "application/json", "X-KEZ-Auth": `${ts}:${bytesToHex(sig)}`, }, body: JSON.stringify({ proofs: subjects }), }); if (!resp.ok) { throw new ApiError(resp.status, `setProofs → ${resp.status}`); } } export async function register( signed: SignedRegistration, ): Promise { const resp = await fetch(url("/v1/register"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(signed), }); return unwrap(resp); }