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>
121 lines
3.4 KiB
TypeScript
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);
|
|
}
|