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>
This commit is contained in:
parent
fc75b27ac6
commit
a2538b2886
@ -41,6 +41,7 @@ pub fn router(state: AppState) -> axum::Router {
|
||||
.route("/v1/u/:handle", get(lookup))
|
||||
.route("/v1/by-primary/:primary", get(lookup_by_primary))
|
||||
.route("/v1/register", post(register))
|
||||
.route("/v1/profile/:handle/proofs", axum::routing::put(set_proofs))
|
||||
.route("/v1/messages", post(crate::messages::send_message))
|
||||
.route("/v1/inbox/:handle", get(crate::messages::inbox))
|
||||
.route("/v1/inbox/:handle/stream", get(crate::messages::stream_inbox))
|
||||
@ -81,6 +82,9 @@ pub struct HandleResponse {
|
||||
pub primary: String, // e.g. "ed25519:abc..."
|
||||
pub sigchain_url: String, // where the sigchain lives
|
||||
pub registered_at: String,
|
||||
/// Claim subjects the user published for discovery (e.g.
|
||||
/// ["github:alice"]). Peers verify each independently. Empty if none.
|
||||
pub proofs: Vec<String>,
|
||||
}
|
||||
|
||||
async fn lookup(
|
||||
@ -126,6 +130,11 @@ fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
|
||||
id
|
||||
),
|
||||
registered_at: record.registered_at.to_rfc3339(),
|
||||
proofs: record
|
||||
.proofs
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,6 +163,7 @@ async fn register(
|
||||
handle: req.payload.handle.clone(),
|
||||
primary: req.payload.primary.clone(),
|
||||
registered_at: Utc::now(),
|
||||
proofs: None,
|
||||
};
|
||||
state.store.register(&record).await?;
|
||||
|
||||
@ -163,6 +173,72 @@ async fn register(
|
||||
))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PUT /v1/profile/:handle/proofs — publish your claim subjects (authed)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetProofsRequest {
|
||||
/// Claim subjects, e.g. ["github:alice","dns:alice.com"].
|
||||
pub proofs: Vec<String>,
|
||||
}
|
||||
|
||||
async fn set_proofs(
|
||||
State(state): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<SetProofsRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
validate_handle(&handle)
|
||||
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
||||
|
||||
let record = state.store.lookup(&handle).await?.ok_or(ApiError::NotFound)?;
|
||||
|
||||
// Auth: X-KEZ-Auth: <unix_ts>:<sig>, signed by the handle's primary
|
||||
// over the canonical request line. Same 60s skew window as inbox.
|
||||
let auth = headers
|
||||
.get("X-KEZ-Auth")
|
||||
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
|
||||
verify_profile_auth(auth, &handle, record.primary.value(), Utc::now().timestamp())?;
|
||||
|
||||
// Cap to keep profiles small; reject absurd payloads.
|
||||
if req.proofs.len() > 64 {
|
||||
return Err(ApiError::BadRequest("too many proofs (max 64)".into()));
|
||||
}
|
||||
let json = serde_json::to_string(&req.proofs)?;
|
||||
state.store.set_proofs(&handle, &json).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Canonical message the proofs-setter signs. Distinct first line from
|
||||
/// the inbox/stream auth so signatures can't be cross-replayed.
|
||||
pub fn canonical_profile_message(handle: &str, ts: i64) -> String {
|
||||
format!("PUT\n/v1/profile/{handle}/proofs\n{ts}")
|
||||
}
|
||||
|
||||
fn verify_profile_auth(
|
||||
auth: &str,
|
||||
handle: &str,
|
||||
pubkey_hex: &str,
|
||||
now_ts: i64,
|
||||
) -> Result<(), ApiError> {
|
||||
let (ts_str, sig_hex) = auth
|
||||
.split_once(':')
|
||||
.ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be <ts>:<sig>".into()))?;
|
||||
let ts: i64 = ts_str
|
||||
.parse()
|
||||
.map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?;
|
||||
if (now_ts - ts).abs() > 60 {
|
||||
return Err(ApiError::Unauthorized("auth header is stale".into()));
|
||||
}
|
||||
let message = canonical_profile_message(handle, ts);
|
||||
kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex)
|
||||
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /.well-known/webfinger — fediverse-style discovery
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -15,6 +15,10 @@ pub struct HandleRecord {
|
||||
pub handle: String,
|
||||
pub primary: Identity,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
/// JSON array of claim subjects the user has published to their profile
|
||||
/// (e.g. ["github:alice","dns:alice.com"]). Discovery only — peers
|
||||
/// independently verify each against the channel. None until set.
|
||||
pub proofs: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -69,36 +73,26 @@ impl Store {
|
||||
let conn = self.inner.lock().await;
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT handle, primary_id, registered_at
|
||||
"SELECT handle, primary_id, registered_at, proofs
|
||||
FROM handles WHERE handle = ?1",
|
||||
params![handle],
|
||||
|row| {
|
||||
let handle: String = row.get(0)?;
|
||||
let primary_id: String = row.get(1)?;
|
||||
let registered_at: String = row.get(2)?;
|
||||
Ok((handle, primary_id, registered_at))
|
||||
},
|
||||
row_to_record_parts,
|
||||
)
|
||||
.optional()?;
|
||||
row.map(build_record).transpose()
|
||||
}
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some((handle, primary_id, registered_at)) => {
|
||||
let primary = Identity::parse(primary_id).map_err(|e| {
|
||||
ApiError::Internal(format!("stored primary not parseable: {e}"))
|
||||
})?;
|
||||
let registered_at = DateTime::parse_from_rfc3339(®istered_at)
|
||||
.map_err(|e| {
|
||||
ApiError::Internal(format!("stored timestamp not parseable: {e}"))
|
||||
})?
|
||||
.with_timezone(&Utc);
|
||||
Ok(Some(HandleRecord {
|
||||
handle,
|
||||
primary,
|
||||
registered_at,
|
||||
}))
|
||||
}
|
||||
/// Replace the published proof-subject list for `handle`.
|
||||
pub async fn set_proofs(&self, handle: &str, proofs_json: &str) -> Result<(), ApiError> {
|
||||
let conn = self.inner.lock().await;
|
||||
let n = conn.execute(
|
||||
"UPDATE handles SET proofs = ?1 WHERE handle = ?2",
|
||||
params![proofs_json, handle],
|
||||
)?;
|
||||
if n == 0 {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up the record for a primary key — used by the NATS auth
|
||||
@ -111,45 +105,48 @@ impl Store {
|
||||
let conn = self.inner.lock().await;
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT handle, primary_id, registered_at
|
||||
"SELECT handle, primary_id, registered_at, proofs
|
||||
FROM handles WHERE primary_id = ?1",
|
||||
params![primary.to_string()],
|
||||
|row| {
|
||||
let handle: String = row.get(0)?;
|
||||
let primary_id: String = row.get(1)?;
|
||||
let registered_at: String = row.get(2)?;
|
||||
Ok((handle, primary_id, registered_at))
|
||||
},
|
||||
row_to_record_parts,
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some((handle, primary_id, registered_at)) => {
|
||||
let primary = Identity::parse(primary_id).map_err(|e| {
|
||||
ApiError::Internal(format!("stored primary not parseable: {e}"))
|
||||
})?;
|
||||
let registered_at = DateTime::parse_from_rfc3339(®istered_at)
|
||||
.map_err(|e| {
|
||||
ApiError::Internal(format!("stored timestamp not parseable: {e}"))
|
||||
})?
|
||||
.with_timezone(&Utc);
|
||||
Ok(Some(HandleRecord {
|
||||
handle,
|
||||
primary,
|
||||
registered_at,
|
||||
}))
|
||||
}
|
||||
}
|
||||
row.map(build_record).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
type RecordParts = (String, String, String, Option<String>);
|
||||
|
||||
fn row_to_record_parts(row: &rusqlite::Row) -> rusqlite::Result<RecordParts> {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
}
|
||||
|
||||
fn build_record(parts: RecordParts) -> Result<HandleRecord, ApiError> {
|
||||
let (handle, primary_id, registered_at, proofs) = parts;
|
||||
let primary = Identity::parse(primary_id)
|
||||
.map_err(|e| ApiError::Internal(format!("stored primary not parseable: {e}")))?;
|
||||
let registered_at = DateTime::parse_from_rfc3339(®istered_at)
|
||||
.map_err(|e| ApiError::Internal(format!("stored timestamp not parseable: {e}")))?
|
||||
.with_timezone(&Utc);
|
||||
Ok(HandleRecord {
|
||||
handle,
|
||||
primary,
|
||||
registered_at,
|
||||
proofs,
|
||||
})
|
||||
}
|
||||
|
||||
fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
// New column for existing databases — ignore "duplicate column" so
|
||||
// re-running on an already-migrated DB is a no-op.
|
||||
let _ = conn.execute("ALTER TABLE handles ADD COLUMN proofs TEXT", []);
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS handles (
|
||||
handle TEXT NOT NULL PRIMARY KEY,
|
||||
primary_id TEXT NOT NULL UNIQUE,
|
||||
registered_at TEXT NOT NULL
|
||||
registered_at TEXT NOT NULL,
|
||||
proofs TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_handles_primary
|
||||
ON handles (primary_id);
|
||||
|
||||
27
kez-chat/web/src/lib/VerifiedBadge.svelte
Normal file
27
kez-chat/web/src/lib/VerifiedBadge.svelte
Normal file
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
// Verified check — shown next to a KEZ that has ≥1 cryptographically
|
||||
// verified proof (the client checked it, X/Twitter-style). Green so it
|
||||
// reads as "verified", distinct from the cyan brand accent.
|
||||
interface Props {
|
||||
size?: number;
|
||||
/** Tooltip text. */
|
||||
title?: string;
|
||||
}
|
||||
let { size = 16, title = "Verified — controls a proven account" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block shrink-0 align-text-bottom"
|
||||
style="color: var(--color-verified)"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="verified"
|
||||
{title}
|
||||
>
|
||||
<!-- Scalloped seal + check, the familiar verified glyph. -->
|
||||
<path d="M12 1.5l2.3 1.7 2.85-.2 1 2.67 2.45 1.46-.83 2.74.83 2.74-2.45 1.46-1 2.67-2.85-.2L12 22.5l-2.3-1.7-2.85.2-1-2.67-2.45-1.46.83-2.74-.83-2.74 2.45-1.46 1-2.67 2.85.2z"/>
|
||||
<path d="M10.6 14.6l-2.2-2.2-1.4 1.4 3.6 3.6 6-6-1.4-1.4z" fill="var(--color-bg)"/>
|
||||
</svg>
|
||||
@ -1,6 +1,8 @@
|
||||
// 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 {
|
||||
@ -9,6 +11,8 @@ export interface HandleResponse {
|
||||
primary: string;
|
||||
sigchain_url: string;
|
||||
registered_at: string;
|
||||
/** Claim subjects the user published for discovery, e.g. ["github:alice"]. */
|
||||
proofs: string[];
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
@ -78,6 +82,32 @@ export async function lookupByPrimary(primary: string): Promise<HandleResponse>
|
||||
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> {
|
||||
|
||||
@ -34,6 +34,10 @@ export interface Conversation {
|
||||
messages: ConversationMessage[];
|
||||
/** Max server-seq we've processed for any message from this peer. */
|
||||
last_seq: number;
|
||||
/** Whether ≥1 of the peer's published proofs verified (the badge). */
|
||||
verified?: boolean;
|
||||
/** ISO timestamp of the last verification check (24h cache window). */
|
||||
verified_checked_at?: string;
|
||||
}
|
||||
|
||||
interface Store {
|
||||
@ -99,6 +103,19 @@ export async function ensureConversation(
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Record a peer's verification result (drives the verified badge). */
|
||||
export async function setVerified(
|
||||
peer_primary: Identity,
|
||||
verified: boolean,
|
||||
): Promise<void> {
|
||||
const s = await read();
|
||||
const conv = s.by_peer[peer_primary];
|
||||
if (!conv) return;
|
||||
conv.verified = verified;
|
||||
conv.verified_checked_at = new Date().toISOString();
|
||||
await write(s);
|
||||
}
|
||||
|
||||
export async function appendInbound(opts: {
|
||||
peer_primary: Identity;
|
||||
peer_handle: string;
|
||||
|
||||
@ -48,4 +48,34 @@ export async function verifyClaim(claim: StoredClaim): Promise<VerifyResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that `primary` genuinely controls `subject` by fetching the
|
||||
* proof from the subject's channel and checking the signature — same
|
||||
* real check as verifyClaim, but driven by just (subject, primary)
|
||||
* since we don't hold a peer's envelope locally. Used for the verified
|
||||
* badge on other people in chat.
|
||||
*
|
||||
* The channel is the subject prefix (github:, dns:, …). Verifiers read
|
||||
* ctx.subject + ctx.primary; `expected` is unused by them, so a stub is
|
||||
* fine here.
|
||||
*/
|
||||
export async function verifySubject(
|
||||
subject: string,
|
||||
primary: string,
|
||||
): Promise<boolean> {
|
||||
const channel = subject.split(":")[0];
|
||||
const handler = REGISTRY[channel];
|
||||
if (!handler) return false;
|
||||
try {
|
||||
const res = await handler({
|
||||
subject,
|
||||
primary,
|
||||
expected: undefined as never,
|
||||
});
|
||||
return res.status === "ok";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type { VerifyResult } from "./verifiers/types.js";
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { lookup, ApiError } from "../lib/api.js";
|
||||
import { lookup, setProofs, ApiError } from "../lib/api.js";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
|
||||
import {
|
||||
listClaims,
|
||||
setVerifyResult,
|
||||
@ -43,11 +44,30 @@
|
||||
await setVerifyResult(c.id, result);
|
||||
}
|
||||
claims = await listClaims();
|
||||
await publishVerifiedSubjects();
|
||||
} finally {
|
||||
verifyingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the subjects of our currently-verified claims to the server
|
||||
* profile so peers can discover + independently verify them (drives
|
||||
* their verified badge for us). Best-effort — a failure here doesn't
|
||||
* affect local verification.
|
||||
*/
|
||||
async function publishVerifiedSubjects() {
|
||||
if (!session.unlocked) return;
|
||||
const subjects = claims
|
||||
.filter((c) => c.last_verify?.status === "ok")
|
||||
.map((c) => c.envelope.payload.subject);
|
||||
try {
|
||||
await setProofs(session.unlocked.handle, session.unlocked.seed, subjects);
|
||||
} catch (e) {
|
||||
console.error("publishVerifiedSubjects failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
return (
|
||||
{
|
||||
@ -87,8 +107,9 @@
|
||||
<Avatar seed={session.unlocked.primary} size={64} ring />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono text-lg font-semibold text-text truncate">
|
||||
{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span>
|
||||
<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>
|
||||
{#if verified.length > 0}<VerifiedBadge size={16} />{/if}
|
||||
</span>
|
||||
<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"
|
||||
|
||||
@ -3,14 +3,17 @@
|
||||
import { push } from "svelte-spa-router";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import { sendMessage } from "../lib/messages.js";
|
||||
import { lookup, ApiError } from "../lib/api.js";
|
||||
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||
import { verifySubject } from "../lib/verify.js";
|
||||
import EmojiButton from "../lib/EmojiButton.svelte";
|
||||
import Avatar from "../lib/Avatar.svelte";
|
||||
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
|
||||
import {
|
||||
appendOutbound,
|
||||
ensureConversation,
|
||||
listConversations,
|
||||
setVerified,
|
||||
type Conversation,
|
||||
} from "../lib/conversations-store.js";
|
||||
import type { Identity } from "../lib/kez.js";
|
||||
@ -153,6 +156,43 @@
|
||||
conversations = await listConversations();
|
||||
}
|
||||
|
||||
// Verify the peer whenever a conversation is opened. The 24h cache in
|
||||
// verifyPeer makes this a no-op on repeat opens (and after setVerified
|
||||
// refreshes the list, so no loop).
|
||||
$effect(() => {
|
||||
const conv = activeConv;
|
||||
if (conv) void verifyPeer(conv);
|
||||
});
|
||||
|
||||
const VERIFY_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Verify a peer's published proofs (24h cache). Fetches their claimed
|
||||
* subjects from the server, runs the real channel verifiers against
|
||||
* each, and caches whether ≥1 passed → drives the verified badge.
|
||||
* Runs in the background on conversation open; never blocks the UI.
|
||||
*/
|
||||
async function verifyPeer(conv: Conversation) {
|
||||
const fresh =
|
||||
conv.verified_checked_at &&
|
||||
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
|
||||
if (fresh) return;
|
||||
try {
|
||||
const record = await lookupByPrimary(conv.peer_primary);
|
||||
let ok = false;
|
||||
for (const subject of record.proofs ?? []) {
|
||||
if (await verifySubject(subject, conv.peer_primary)) {
|
||||
ok = true;
|
||||
break; // one verified proof is enough for the badge
|
||||
}
|
||||
}
|
||||
await setVerified(conv.peer_primary, ok);
|
||||
await refresh();
|
||||
} catch {
|
||||
// Peer not resolvable / offline channels — leave badge as-is.
|
||||
}
|
||||
}
|
||||
|
||||
/** "Start chat with handle" — resolve, ensure conversation, open it. */
|
||||
async function startConversation() {
|
||||
if (!session.unlocked || !newPeerInput.trim()) return;
|
||||
@ -322,7 +362,10 @@
|
||||
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
|
||||
<Avatar seed={c.peer_primary} size={40} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(c)}</p>
|
||||
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||
<span class="truncate">{displayName(c)}</span>
|
||||
{#if c.verified}<VerifiedBadge size={14} />{/if}
|
||||
</p>
|
||||
{#if last}
|
||||
<p class="text-xs text-text-secondary truncate">
|
||||
{last.direction === "out" ? "→ " : ""}{last.body}
|
||||
@ -365,7 +408,10 @@
|
||||
</button>
|
||||
<Avatar seed={activeConv.peer_primary} size={36} />
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(activeConv)}</p>
|
||||
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||
<span class="truncate">{displayName(activeConv)}</span>
|
||||
{#if activeConv.verified}<VerifiedBadge size={15} />{/if}
|
||||
</p>
|
||||
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user