Jason Tudisco a2538b2886 feat(kez-chat): verified-user badge in chat (X/Twitter-style, but real)
A green check next to any KEZ that controls a proven account. Unlike
Twitter's "we say so," the badge means YOUR browser independently
verified ≥1 of the peer's published proofs against the channel.

Server:
  • handles.proofs column (JSON array of claim subjects) + ALTER for
    existing DBs. Returned in /v1/u/:handle and /v1/by-primary as
    `proofs` — pure discovery; peers verify each themselves.
  • PUT /v1/profile/:handle/proofs (authed X-KEZ-Auth, signed over
    "PUT\n/v1/profile/<h>/proofs\n<ts>", distinct line from inbox/stream
    so sigs can't cross-replay; 60s skew; max 64 subjects).
  • All 20 existing http tests still pass.

Client:
  • api.ts: HandleResponse.proofs + setProofs() (signs + PUTs).
  • verify.ts: verifySubject(subject, primary) — runs the real channel
    verifier given just subject+primary (no local envelope needed).
  • conversations-store: cache verified + verified_checked_at per peer.
  • Messages: on conversation open, fetch the peer's proof subjects and
    verify them in the background (24h cache → snappy, rate-limit
    friendly). VerifiedBadge in the conversation row + thread header.
  • Identity: reverify now publishes your verified subjects to your
    profile (so peers can discover them) + shows the badge on your own
    card.
  • VerifiedBadge.svelte: scalloped-seal check in verified-green
    (distinct from the cyan brand accent).

Flow: you reverify your proofs on Identity → they publish to your
profile → when someone opens a chat with you, their client fetches +
verifies them → you get the check on their screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:40:11 -06:00

121 lines
3.4 KiB
TypeScript

// 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<T>(resp: Response): Promise<T> {
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<T>;
}
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<HandleResponse> {
const resp = await fetch(url(`/v1/u/${encodeURIComponent(handle)}`));
return unwrap(resp);
}
/**
* Reverse lookup: ed25519:<hex> 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<HandleResponse> {
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<void> {
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<HandleResponse> {
const resp = await fetch(url("/v1/register"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(signed),
});
return unwrap(resp);
}