feat(kez-chat/web): in-browser claim verification with per-channel plugins

Plugin layout (one file per channel — easy to extend):
  lib/verifiers/{dns,web,github,nostr,bluesky,ap}.ts
  lib/verifiers/types.ts   — VerifyResult + ok/fail/skipped builders
  lib/verify.ts            — dispatcher routing on claim.channel

Live verifiers (browser-native, no CORS proxy):
  • DNS     — Cloudflare DoH /dns-query, TXT at _kez.<domain>
  • web     — fetch <base>/.well-known/kez.json
  • github  — public gists API for kez.md + <user>/<user> README

Deferred to v0.2 (stubs return "skipped" with a hint):
  • nostr   — needs ws relay pool + NIP-19
  • bluesky — needs AT-Proto client
  • ap      — WebFinger CORS hostile from browsers

Verification flow (all channels):
  1. Fetch the published artifact via the channel's transport
  2. parseAnyEnvelope() handles kez:z1: compact, ```kez fences, or raw
  3. Check subject + primary against the stored claim
  4. Re-canonicalize payload (JCS) and verify ed25519 signature

UI changes on /claims:
  • Status badge per claim: ✓ Verified / ✗ Failed / — Skipped / Not verified
  • Per-claim "Verify" button + a "Verify all" button at the top
  • Expandable details panel showing the evidence URL and any error info
  • Latest result persists in IndexedDB (with $state.snapshot for cloning)

kez.ts gains verifyEnvelope() and parseAnyEnvelope() — also useful to
any future verifier (CLI, sig-server, third-party).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-25 13:40:07 -06:00
parent 8622da2ba4
commit 41f66ae366
11 changed files with 657 additions and 11 deletions

View File

@ -5,6 +5,7 @@
import { get, set } from "idb-keyval";
import type { SignedClaimEnvelope } from "./kez.js";
import type { VerifyResult } from "./verify.js";
const KEY = "kez-chat:claims";
@ -14,6 +15,8 @@ export interface StoredClaim {
channel: string; // "github", "dns", "web", ...
published_at?: string; // user marked it published
notes?: string;
/** Latest verification result, if we've checked. */
last_verify?: VerifyResult;
}
export async function listClaims(): Promise<StoredClaim[]> {
@ -35,6 +38,18 @@ export async function markPublished(id: string): Promise<void> {
}
}
export async function setVerifyResult(
id: string,
result: VerifyResult,
): Promise<void> {
const existing = await listClaims();
const target = existing.find((c) => c.id === id);
if (target) {
target.last_verify = result;
await set(KEY, existing);
}
}
export async function removeClaim(id: string): Promise<void> {
const existing = await listClaims();
await set(

View File

@ -125,6 +125,69 @@ function signWith(
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Verification
// ─────────────────────────────────────────────────────────────────────────────
/**
* Verify an `ed25519-sha512-jcs` signature on an envelope.
*
* Re-canonicalizes the payload, parses the signer's identity (must be
* `ed25519:<hex>`), and checks the signature byte-for-byte. Returns
* true on valid, false on invalid/malformed input never throws on a
* bad sig, only on structurally broken input.
*/
export function verifyEnvelope(env: {
payload: unknown;
signature: SignatureBlock;
}): boolean {
const sigBlock = env.signature;
if (!sigBlock || sigBlock.alg !== ED25519_SHA512_ALG) return false;
if (!sigBlock.key.startsWith("ed25519:")) return false;
const pubkeyHex = sigBlock.key.slice("ed25519:".length);
let pubkey: Uint8Array;
let sig: Uint8Array;
try {
pubkey = hexToBytes(pubkeyHex);
sig = hexToBytes(sigBlock.sig);
} catch {
return false;
}
const jcs = canonicalBytes(env.payload);
try {
return ed25519.verify(sig, jcs, pubkey);
} catch {
return false;
}
}
/**
* Parse any of the three published forms `kez:z1:` compact, a
* fenced ```kez block, or raw JSON — into a SignedClaimEnvelope.
* Throws on parse failure; does NOT verify the signature.
*/
export async function parseAnyEnvelope(
text: string,
): Promise<SignedClaimEnvelope> {
const trimmed = text.trim();
// Look for kez:z1: anywhere in the text (DNS TXT, gist body, etc.).
const compactMatch = trimmed.match(/kez:z1:[A-Za-z0-9_-]+/);
if (compactMatch) {
return fromCompact(compactMatch[0]);
}
// Fenced block: ```kez\n{...}\n```
const fenceMatch = trimmed.match(/```kez\s*\n([\s\S]*?)\n```/);
if (fenceMatch) {
return JSON.parse(fenceMatch[1].trim()) as SignedClaimEnvelope;
}
// Raw JSON — find the first `{` and parse from there.
const jsonStart = trimmed.indexOf("{");
if (jsonStart >= 0) {
return JSON.parse(trimmed.slice(jsonStart)) as SignedClaimEnvelope;
}
throw new Error("no kez envelope found");
}
/** Build + sign a kez.claim envelope ("I control <subject>"). */
export function signClaim(
signer: Ed25519Identity,

View File

@ -0,0 +1,15 @@
// ActivityPub channel verifier — v0.2.
//
// WebFinger → actor JSON → look for the kez fence in `summary` or
// `attachment[].value`. Most fediverse instances do not send CORS
// headers on /.well-known/webfinger, so verifying from the browser
// would require a server-side proxy. Deferred.
import { skipped, type Verifier } from "./types.js";
export const verifyAp: Verifier = async () => {
return skipped(
"ActivityPub verification is coming in v0.2",
"WebFinger lookups require a server-side proxy (CORS). You can still publish the artifact manually.",
);
};

View File

@ -0,0 +1,14 @@
// Bluesky channel verifier — v0.2.
//
// Verification will walk the user's AT-Proto repo for the post that
// contains the kez fence. ATProto client + cursor-paged record listing
// is heavier than what we want to inline for v0.1.
import { skipped, type Verifier } from "./types.js";
export const verifyBluesky: Verifier = async () => {
return skipped(
"Bluesky verification is coming in v0.2",
"Need an AT-Proto client. You can still publish the artifact manually.",
);
};

View File

@ -0,0 +1,98 @@
// DNS channel verifier.
//
// A claim with subject `dns:<domain>` should be published as a TXT
// record at `_kez.<domain>` containing the `kez:z1:` compact form
// (other forms are allowed but compact is the canonical choice for
// DNS — 255-byte segment limits, no fences, no whitespace).
//
// Browsers can't speak DNS directly, so we use DNS-over-HTTPS via
// Cloudflare's public endpoint, which is CORS-friendly.
import { parseAnyEnvelope, verifyEnvelope } from "../kez.js";
import { fail, ok, type Verifier } from "./types.js";
interface DohAnswer {
name: string;
type: number;
TTL: number;
data: string;
}
interface DohResponse {
Status: number;
Answer?: DohAnswer[];
}
const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
export const verifyDns: Verifier = async (ctx) => {
if (!ctx.subject.startsWith("dns:")) {
return fail(`DNS verifier got non-DNS subject: ${ctx.subject}`);
}
const domain = ctx.subject.slice("dns:".length);
const queryName = `_kez.${domain}`;
const url = `${DOH_ENDPOINT}?name=${encodeURIComponent(queryName)}&type=TXT`;
let resp: Response;
try {
resp = await fetch(url, { headers: { Accept: "application/dns-json" } });
} catch (e) {
return fail(`DoH request failed: ${(e as Error).message}`, {
evidence_url: url,
});
}
if (!resp.ok) {
return fail(`DoH responded ${resp.status}`, { evidence_url: url });
}
const json = (await resp.json()) as DohResponse;
if (json.Status !== 0) {
return fail(`DoH status ${json.Status} (NXDOMAIN or SERVFAIL)`, {
evidence_url: url,
});
}
if (!json.Answer || json.Answer.length === 0) {
return fail(`No TXT record at ${queryName}`, { evidence_url: url });
}
// Each TXT answer is a quoted, possibly multi-segment string.
// Strip outer quotes and concatenate segments.
const candidates = json.Answer
.filter((a) => a.type === 16)
.map((a) =>
a.data
.split(/"\s+"/)
.map((s) => s.replace(/^"|"$/g, ""))
.join(""),
);
for (const txt of candidates) {
try {
const fetched = await parseAnyEnvelope(txt);
if (fetched.payload.subject !== ctx.subject) continue;
if (fetched.payload.primary !== ctx.primary) {
return fail(
`TXT envelope's primary (${fetched.payload.primary}) doesn't match your key`,
{ evidence_url: url, fetched },
);
}
if (!verifyEnvelope(fetched)) {
return fail(`TXT envelope signature is invalid`, {
evidence_url: url,
fetched,
});
}
return ok(`Verified via DNS TXT at ${queryName}`, {
evidence_url: url,
fetched,
details: `Fetched ${candidates.length} TXT record(s); signature checked against ${ctx.primary}.`,
});
} catch {
// not a kez envelope — try the next TXT record
continue;
}
}
return fail(`Found TXT records at ${queryName} but none contained a valid kez envelope for ${ctx.subject}`, {
evidence_url: url,
details: `Candidates examined:\n${candidates.map((c) => `${c.slice(0, 80)}${c.length > 80 ? "…" : ""}`).join("\n")}`,
});
};

View File

@ -0,0 +1,127 @@
// GitHub channel verifier.
//
// A claim with subject `github:<username>` can be proven two ways:
// 1. A public gist named `kez.md` containing the ```kez fence.
// 2. The user's profile README at <user>/<user>.
//
// Both endpoints (api.github.com, raw.githubusercontent.com) send CORS
// headers, so the browser can fetch them directly. Unauthenticated
// rate limits are 60 req/hr per IP — generous for our use.
import { parseAnyEnvelope, verifyEnvelope } from "../kez.js";
import { fail, ok, type Verifier, type VerifyResult } from "./types.js";
interface GistFile {
filename: string;
raw_url: string;
}
interface Gist {
id: string;
html_url: string;
files: Record<string, GistFile>;
}
async function tryGists(
ctx: { subject: string; primary: string },
user: string,
): Promise<VerifyResult | null> {
const url = `https://api.github.com/users/${encodeURIComponent(user)}/gists?per_page=100`;
const resp = await fetch(url, {
headers: { Accept: "application/vnd.github+json" },
});
if (!resp.ok) return null;
const gists = (await resp.json()) as Gist[];
for (const g of gists) {
const kezFile = Object.values(g.files).find(
(f) => f.filename.toLowerCase() === "kez.md",
);
if (!kezFile) continue;
try {
const raw = await fetch(kezFile.raw_url).then((r) => r.text());
const fetched = await parseAnyEnvelope(raw);
if (fetched.payload.subject !== ctx.subject) continue;
if (fetched.payload.primary !== ctx.primary) {
return fail(
`Gist envelope's primary doesn't match your key`,
{ evidence_url: g.html_url, fetched },
);
}
if (!verifyEnvelope(fetched)) {
return fail(`Gist envelope signature is invalid`, {
evidence_url: g.html_url,
fetched,
});
}
return ok(`Verified via public gist`, {
evidence_url: g.html_url,
fetched,
});
} catch {
continue;
}
}
return null;
}
async function tryProfileReadme(
ctx: { subject: string; primary: string },
user: string,
): Promise<VerifyResult | null> {
// Try main and master.
for (const branch of ["main", "master"]) {
const url = `https://raw.githubusercontent.com/${user}/${user}/${branch}/README.md`;
const resp = await fetch(url);
if (!resp.ok) continue;
const raw = await resp.text();
try {
const fetched = await parseAnyEnvelope(raw);
if (fetched.payload.subject !== ctx.subject) continue;
if (fetched.payload.primary !== ctx.primary) {
return fail(
`Profile README envelope's primary doesn't match your key`,
{ evidence_url: url, fetched },
);
}
if (!verifyEnvelope(fetched)) {
return fail(`Profile README envelope signature is invalid`, {
evidence_url: url,
fetched,
});
}
return ok(`Verified via ${user}/${user} README`, {
evidence_url: url,
fetched,
});
} catch {
// README doesn't contain a kez envelope — keep going.
continue;
}
}
return null;
}
export const verifyGithub: Verifier = async (ctx) => {
if (!ctx.subject.startsWith("github:")) {
return fail(`GitHub verifier got non-github subject: ${ctx.subject}`);
}
const user = ctx.subject.slice("github:".length);
if (!user) return fail("Empty GitHub username");
const gistResult = await tryGists(ctx, user);
if (gistResult) return gistResult;
const readmeResult = await tryProfileReadme(ctx, user);
if (readmeResult) return readmeResult;
return fail(
`No kez proof found for github:${user}`,
{
evidence_url: `https://gist.github.com/${user}`,
details:
`Looked in:\n` +
` • all public gists for a file named kez.md\n` +
` • https://github.com/${user}/${user}/blob/{main,master}/README.md\n` +
`If you just published it, GitHub's API can lag by a few seconds.`,
},
);
};

View File

@ -0,0 +1,16 @@
// Nostr channel verifier — v0.2.
//
// Verifying a `nostr:<npub>` claim means connecting to a pool of relays
// over WebSocket, subscribing to kind-30078 events tagged d='kez' from
// that pubkey, and checking the event's content is a valid kez envelope.
// That's a chunky dependency (relay list, ws pool, NIP-19 decode) so we
// defer it; the user can still create the claim and copy the artifact.
import { skipped, type Verifier } from "./types.js";
export const verifyNostr: Verifier = async () => {
return skipped(
"Nostr verification is coming in v0.2",
"Need a relay pool + NIP-19 decoder. You can still publish the artifact manually.",
);
};

View File

@ -0,0 +1,73 @@
// Shared types for the per-channel verifier plugins.
//
// Each channel exports a `verify(claim) -> VerifyResult` function. The
// dispatcher in ../verify.ts routes to the right one based on
// claim.channel. The result is rendered on the Claims page.
import type { SignedClaimEnvelope } from "../kez.js";
export type VerifyStatus = "ok" | "fail" | "skipped";
export interface VerifyResult {
status: VerifyStatus;
/** One-line summary for the UI. */
summary: string;
/** URL the verifier fetched (when relevant) — shown as evidence link. */
evidence_url?: string;
/** The envelope as fetched from the channel, when we got one. */
fetched?: SignedClaimEnvelope;
/** Long-form details (multi-line). Shown in an expandable panel. */
details?: string;
/** Wall-clock timestamp the verify ran. */
checked_at: string;
}
export interface VerifierContext {
/** The locally-stored claim we're trying to verify. */
subject: string;
primary: string;
/** The exact bytes we expect to find published. */
expected: SignedClaimEnvelope;
}
export type Verifier = (ctx: VerifierContext) => Promise<VerifyResult>;
/** Build a result that says "we couldn't even try." */
export function skipped(summary: string, details?: string): VerifyResult {
return {
status: "skipped",
summary,
details,
checked_at: new Date().toISOString(),
};
}
/** Build a failure result with optional evidence URL + details. */
export function fail(
summary: string,
opts?: { evidence_url?: string; details?: string; fetched?: SignedClaimEnvelope },
): VerifyResult {
return {
status: "fail",
summary,
evidence_url: opts?.evidence_url,
details: opts?.details,
fetched: opts?.fetched,
checked_at: new Date().toISOString(),
};
}
/** Build a success result. */
export function ok(
summary: string,
opts?: { evidence_url?: string; details?: string; fetched?: SignedClaimEnvelope },
): VerifyResult {
return {
status: "ok",
summary,
evidence_url: opts?.evidence_url,
details: opts?.details,
fetched: opts?.fetched,
checked_at: new Date().toISOString(),
};
}

View File

@ -0,0 +1,61 @@
// Web channel verifier.
//
// A claim with subject `web:<https-url>` should be published at
// `<https-url>/.well-known/kez.json` — the raw signed envelope JSON.
// We fetch it and verify the signature. CORS is up to the target site;
// many sites allow it on .well-known/, but if not the user sees the
// browser's CORS error in the UI.
import { verifyEnvelope, type SignedClaimEnvelope } from "../kez.js";
import { fail, ok, type Verifier } from "./types.js";
export const verifyWeb: Verifier = async (ctx) => {
if (!ctx.subject.startsWith("web:")) {
return fail(`Web verifier got non-web subject: ${ctx.subject}`);
}
const base = ctx.subject.slice("web:".length).replace(/\/$/, "");
const url = `${base}/.well-known/kez.json`;
let resp: Response;
try {
resp = await fetch(url, { headers: { Accept: "application/json" } });
} catch (e) {
return fail(
`Fetch failed (likely CORS): ${(e as Error).message}`,
{
evidence_url: url,
details:
`If the file is there but the browser blocks it, the site needs to send\n` +
` Access-Control-Allow-Origin: *\n` +
`on /.well-known/kez.json. Most static hosts can configure this in one line.`,
},
);
}
if (!resp.ok) {
return fail(`${url} responded ${resp.status}`, { evidence_url: url });
}
let fetched: SignedClaimEnvelope;
try {
fetched = (await resp.json()) as SignedClaimEnvelope;
} catch (e) {
return fail(`Response was not JSON: ${(e as Error).message}`, {
evidence_url: url,
});
}
if (fetched.payload?.subject !== ctx.subject) {
return fail(
`Envelope subject (${fetched.payload?.subject}) doesn't match the claim (${ctx.subject})`,
{ evidence_url: url, fetched },
);
}
if (fetched.payload.primary !== ctx.primary) {
return fail(
`Envelope primary (${fetched.payload.primary}) doesn't match your key`,
{ evidence_url: url, fetched },
);
}
if (!verifyEnvelope(fetched)) {
return fail(`Signature invalid`, { evidence_url: url, fetched });
}
return ok(`Verified via ${url}`, { evidence_url: url, fetched });
};

View File

@ -0,0 +1,51 @@
// Channel dispatcher for claim verification.
//
// The Claims page calls verifyClaim(storedClaim) — we look at
// claim.channel, hand off to the right plugin in ./verifiers/, and
// return its result.
import type { StoredClaim } from "./claims-store.js";
import { verifyDns } from "./verifiers/dns.js";
import { verifyWeb } from "./verifiers/web.js";
import { verifyGithub } from "./verifiers/github.js";
import { verifyNostr } from "./verifiers/nostr.js";
import { verifyBluesky } from "./verifiers/bluesky.js";
import { verifyAp } from "./verifiers/ap.js";
import {
fail,
skipped,
type Verifier,
type VerifierContext,
type VerifyResult,
} from "./verifiers/types.js";
const REGISTRY: Record<string, Verifier> = {
dns: verifyDns,
web: verifyWeb,
github: verifyGithub,
nostr: verifyNostr,
bluesky: verifyBluesky,
ap: verifyAp,
};
export async function verifyClaim(claim: StoredClaim): Promise<VerifyResult> {
const handler = REGISTRY[claim.channel];
if (!handler) {
return skipped(`No verifier registered for channel "${claim.channel}"`);
}
const ctx: VerifierContext = {
subject: claim.envelope.payload.subject,
primary: claim.envelope.payload.primary,
expected: claim.envelope,
};
try {
return await handler(ctx);
} catch (e) {
return fail(
`Verifier crashed: ${(e as Error).message}`,
{ details: (e as Error).stack },
);
}
}
export type { VerifyResult } from "./verifiers/types.js";

View File

@ -5,12 +5,18 @@
listClaims,
markPublished,
removeClaim,
setVerifyResult,
type StoredClaim,
} from "../lib/claims-store.js";
import { verifyClaim } from "../lib/verify.js";
import { session } from "../lib/store.svelte.js";
let claims = $state<StoredClaim[]>([]);
let loading = $state(true);
/** ids currently mid-verify, so we can disable the button + show a spinner. */
let verifying = $state<Set<string>>(new Set());
/** Which claims have their details panel expanded. */
let expanded = $state<Set<string>>(new Set());
onMount(async () => {
if (!session.unlocked) {
@ -31,24 +37,83 @@
await removeClaim(c.id);
claims = await listClaims();
}
async function runVerify(c: StoredClaim) {
verifying = new Set(verifying).add(c.id);
try {
// $state.snapshot — IDB can't clone proxies (see AddClaim.svelte fix).
const result = await verifyClaim($state.snapshot(c) as StoredClaim);
await setVerifyResult(c.id, result);
claims = await listClaims();
} catch (e) {
console.error("verify failed", e);
alert(`Verify crashed: ${(e as Error).message}`);
} finally {
const next = new Set(verifying);
next.delete(c.id);
verifying = next;
}
}
async function verifyAll() {
for (const c of claims) {
await runVerify(c);
}
}
function toggleExpand(id: string) {
const next = new Set(expanded);
if (next.has(id)) next.delete(id);
else next.add(id);
expanded = next;
}
function statusBadge(c: StoredClaim) {
const v = c.last_verify;
if (!v) return { text: "Not verified", color: "bg-gray-100 text-gray-600" };
if (v.status === "ok") return { text: "✓ Verified", color: "bg-green-100 text-green-800" };
if (v.status === "fail") return { text: "✗ Failed", color: "bg-red-100 text-red-800" };
return { text: "— Skipped", color: "bg-yellow-100 text-yellow-800" };
}
function formatChecked(iso: string): string {
const d = new Date(iso);
const diffSec = (Date.now() - d.getTime()) / 1000;
if (diffSec < 60) return "just now";
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} min ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)} h ago`;
return d.toLocaleString();
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Claims</h1>
<a
href="#/claims/add"
class="px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
>
+ Add claim
</a>
<div class="flex gap-2">
{#if claims.length > 0}
<button
class="px-3 py-2 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
onclick={verifyAll}
disabled={verifying.size > 0}
>
{verifying.size > 0 ? `Verifying ${verifying.size}` : "Verify all"}
</button>
{/if}
<a
href="#/claims/add"
class="px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
>
+ Add claim
</a>
</div>
</div>
<p class="text-sm text-gray-700">
A claim is a signed envelope that says "I control this other account."
Publish the proof on the channel itself (a public gist, a DNS TXT
record, a nostr event, etc.) and anyone can verify it without
trusting this server.
trusting this server. Hit <strong>Verify</strong> to have your
browser fetch the proof and check the signature.
</p>
{#if loading}
@ -66,17 +131,58 @@
{:else}
<ul class="space-y-3">
{#each claims as c (c.id)}
{@const badge = statusBadge(c)}
{@const isVerifying = verifying.has(c.id)}
{@const isExpanded = expanded.has(c.id)}
<li class="border border-gray-200 rounded-lg p-4 bg-white">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="font-mono font-semibold text-gray-900 truncate">
{c.envelope.payload.subject}
</p>
<div class="flex items-center gap-2 flex-wrap">
<p class="font-mono font-semibold text-gray-900 truncate">
{c.envelope.payload.subject}
</p>
<span class={`text-xs px-2 py-0.5 rounded font-medium ${badge.color}`}>
{badge.text}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
Channel: <span class="font-mono">{c.channel}</span> ·
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
</p>
{#if c.published_at}
{#if c.last_verify}
<p class="mt-1 text-xs text-gray-700">
{c.last_verify.summary}
<span class="text-gray-400">· checked {formatChecked(c.last_verify.checked_at)}</span>
</p>
{#if c.last_verify.evidence_url || c.last_verify.details}
<button
class="mt-1 text-xs text-gray-500 hover:text-gray-900 underline"
onclick={() => toggleExpand(c.id)}
>
{isExpanded ? "Hide" : "Show"} details
</button>
{#if isExpanded}
<div class="mt-2 text-xs bg-gray-50 border border-gray-200 rounded p-3 space-y-2">
{#if c.last_verify.evidence_url}
<div>
<span class="text-gray-500">Evidence URL:</span>
<a
href={c.last_verify.evidence_url}
target="_blank"
rel="noopener noreferrer"
class="font-mono text-blue-700 hover:underline break-all"
>
{c.last_verify.evidence_url}
</a>
</div>
{/if}
{#if c.last_verify.details}
<pre class="whitespace-pre-wrap font-mono text-gray-700">{c.last_verify.details}</pre>
{/if}
</div>
{/if}
{/if}
{:else if c.published_at}
<p class="mt-1 text-xs text-green-700">
✓ You marked this published at {c.published_at}
</p>
@ -87,6 +193,13 @@
{/if}
</div>
<div class="flex flex-col gap-2 shrink-0">
<button
class="text-xs px-3 py-1 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
onclick={() => runVerify(c)}
disabled={isVerifying}
>
{isVerifying ? "Verifying…" : "Verify"}
</button>
{#if !c.published_at}
<button
class="text-xs px-3 py-1 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"