feat(kez-chat/web): show verified badge in chat; require 2+ proofs

The verified checkmark only appeared on the profile, never in chat, even
for clearly-verified peers. Three gaps fixed:

- Chat verified only the open conversation, so list items never showed a
  badge. Verify all conversations on load (24h per-peer cache).
- A peer's proofs were only published to the server on a manual "reverify
  all", so verified users were invisible to peers. Auto-publish verified
  subjects when the Identity page loads.
- Unify the threshold: a badge now requires >=2 independently-verified
  proofs, in both chat (VERIFY_MIN_PROOFS) and the profile (isVerified),
  so "verified" means the same thing everywhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-28 14:37:13 -06:00
parent 41f9442650
commit 7bbf8baf86
2 changed files with 48 additions and 25 deletions

View File

@ -21,12 +21,25 @@
const failed = $derived(claims.filter((c) => c.last_verify?.status === "fail")); const failed = $derived(claims.filter((c) => c.last_verify?.status === "fail"));
const pending = $derived(claims.filter((c) => !c.last_verify)); const pending = $derived(claims.filter((c) => !c.last_verify));
// Verified badge requires at least this many independently-verified
// proofs. Kept in sync with VERIFY_MIN_PROOFS in Messages.svelte so the
// badge means the same thing on the profile and in chat.
const VERIFY_MIN_PROOFS = 2;
const isVerified = $derived(verified.length >= VERIFY_MIN_PROOFS);
onMount(async () => { onMount(async () => {
if (!session.unlocked) { if (!session.unlocked) {
push("/unlock"); push("/unlock");
return; return;
} }
claims = await listClaims(); claims = await listClaims();
// Publish our verified proof subjects to the server profile so peers
// can discover + independently verify them (drives our badge in their
// chat). Previously this only happened on a manual "reverify all", so
// verified users were invisible to peers until they clicked it.
if (claims.some((c) => c.last_verify?.status === "ok")) {
void publishVerifiedSubjects();
}
try { try {
registryRecord = await lookup(session.unlocked.handle); registryRecord = await lookup(session.unlocked.handle);
} catch (e) { } catch (e) {
@ -109,7 +122,7 @@
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1"> <span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">
<span class="truncate">{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span></span> <span class="truncate">{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span></span>
{#if verified.length > 0}<VerifiedBadge size={16} />{/if} {#if isVerified}<VerifiedBadge size={16} />{/if}
</span> </span>
<button <button
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0" class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"

View File

@ -140,6 +140,10 @@
return; return;
} }
await refresh(); await refresh();
// Kick off verification for every existing conversation (24h cache per
// peer), so the verified badge shows in the list without opening each
// thread one by one.
void verifyPeers(conversations);
// Subscribe to the always-on inbox service — re-render whenever a // Subscribe to the always-on inbox service — re-render whenever a
// new message lands. The service is already running (it started on // new message lands. The service is already running (it started on
// session unlock in store.svelte.ts) regardless of which route the // session unlock in store.svelte.ts) regardless of which route the
@ -157,41 +161,47 @@
conversations = await listConversations(); conversations = await listConversations();
} }
// Verify the peer whenever a conversation is opened. The 24h cache in // Verify the active peer whenever a conversation is opened (covers
// verifyPeer makes this a no-op on repeat opens (and after setVerified // conversations started/arrived this session). The 24h per-peer cache
// refreshes the list, so no loop). // makes this a no-op on repeat opens, so the post-verify refresh can't loop.
$effect(() => { $effect(() => {
const conv = activeConv; const conv = activeConv;
if (conv) void verifyPeer(conv); if (conv) void verifyPeers([conv]);
}); });
const VERIFY_CACHE_MS = 24 * 60 * 60 * 1000; const VERIFY_CACHE_MS = 24 * 60 * 60 * 1000;
/** A peer earns the verified badge once at least this many of their
* published proofs independently check out. Kept in sync with the
* profile rule in Identity.svelte. */
const VERIFY_MIN_PROOFS = 2;
/** /**
* Verify a peer's published proofs (24h cache). Fetches their claimed * Verify a batch of peers' published proofs (24h cache per peer). For
* subjects from the server, runs the real channel verifiers against * each peer we fetch their claimed subjects from the server, run the
* each, and caches whether ≥1 passed → drives the verified badge. * real channel verifiers, count how many pass, and set the verified
* Runs in the background on conversation open; never blocks the UI. * badge when ≥ VERIFY_MIN_PROOFS. Refreshes the list once at the end if
* anything changed. Runs in the background; never blocks the UI.
*/ */
async function verifyPeer(conv: Conversation) { async function verifyPeers(convs: Conversation[]) {
const fresh = let changed = false;
conv.verified_checked_at && for (const conv of convs) {
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS; const fresh =
if (fresh) return; conv.verified_checked_at &&
try { Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
const record = await lookupByPrimary(conv.peer_primary); if (fresh) continue;
let ok = false; try {
for (const subject of record.proofs ?? []) { const record = await lookupByPrimary(conv.peer_primary);
if (await verifySubject(subject, conv.peer_primary)) { let verifiedCount = 0;
ok = true; for (const subject of record.proofs ?? []) {
break; // one verified proof is enough for the badge if (await verifySubject(subject, conv.peer_primary)) verifiedCount++;
} }
await setVerified(conv.peer_primary, verifiedCount >= VERIFY_MIN_PROOFS);
changed = true;
} catch {
// Peer not resolvable / offline channels — leave badge as-is.
} }
await setVerified(conv.peer_primary, ok);
await refresh();
} catch {
// Peer not resolvable / offline channels — leave badge as-is.
} }
if (changed) await refresh();
} }
/** "Start chat with handle" — resolve, ensure conversation, open it. */ /** "Start chat with handle" — resolve, ensure conversation, open it. */