Kez/kez-chat/web/src/routes/AddClaim.svelte
Jason Tudisco 5ad47a917d feat(kez-chat/web): mirror local claims to chain-service sigchain
When the user adds a claim, append an `add` event to their sigchain
on the chain service (rust-sig-server); when they remove a claim,
append a `revoke`. Implements SPEC.md §8 — the sigchain is now the
canonical, verifiable record of what the user currently claims, not
a per-claim field.

  • lib/sigchain-service.ts: new module that fetches the current chain
    to compute the next seq + prev hash, signs the event locally (the
    chain service never sees the seed), and POSTs. Returns a typed
    SigchainSyncResult so the caller can record the seq + status.
  • lib/kez.ts: sigchain event types + helpers (nextChainCursor,
    sigchainEventHash, signSigchainEvent, SignedSigchainEvent /
    SigchainOp types). Mirrors the Rust + Node + Python core surface.
  • lib/api.ts: getSigchain (GET full chain) + postSigchainEvent
    (POST one signed event) wrappers against the chain service.
  • lib/claims-store: StoredClaim gains chain_service, sigchain_seq,
    sigchain_status ("synced" | "error"), sigchain_error fields +
    setSigchainSync helper.
  • routes/AddClaim: on successful claim creation, fires an `add` to
    the chain service in the background; surfaces sync errors with a
    "Retry sync" button.
  • routes/Claims: a `revoke` is posted to the chain service first
    when the user removes a claim. Best-effort — if the service is
    unreachable, asks before dropping the local copy so the chain
    doesn't silently drift. Per-row "Sync to chain" button retries
    failed adds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 22:43:16 -06:00

545 lines
21 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import {
signClaim,
identityFromSeed,
toPrettyJson,
toMarkdown,
toCompact,
type SignedClaimEnvelope,
} from "../lib/kez.js";
import { addClaim, setSigchainSync } from "../lib/claims-store.js";
import {
appendSubjectEvent,
resolveChainService,
} from "../lib/sigchain-service.js";
import { session } from "../lib/store.svelte.js";
import {
hasNip07,
getNostrNpub,
publishKezClaimToNostr,
type PublishResult,
} from "../lib/nip07.js";
type ChannelKey = "github" | "dns" | "web" | "nostr" | "bluesky" | "ap";
type Format = "compact" | "markdown" | "json";
interface ChannelDef {
key: ChannelKey;
label: string;
identifierPlaceholder: string;
identifierLabel: string;
/** Build the canonical KEZ identifier from the user's input. */
toSubject: (raw: string) => string;
/** Free-text instructions shown next to the publish artifact. */
instructions: (subject: string) => string;
/** Which encoding to show by default. */
preferredFormat: Format;
}
const CHANNELS: ChannelDef[] = [
{
key: "github",
label: "GitHub",
identifierLabel: "GitHub username",
identifierPlaceholder: "tudisco",
toSubject: (s) => `github:${s.trim()}`,
preferredFormat: "markdown",
instructions: (subject) =>
`1. Create a new PUBLIC gist on github.com.\n` +
`2. Filename: \`kez.md\`\n` +
`3. Paste the markdown block on the right as the file content.\n` +
`4. (Alternative) Add the same markdown block to your <user>/<user> profile README.\n\n` +
`A verifier resolving ${subject} will find the gist via the public gist listing API.`,
},
{
key: "dns",
label: "DNS",
identifierLabel: "Domain",
identifierPlaceholder: "tudisco.com",
toSubject: (s) => `dns:${s.trim().toLowerCase()}`,
preferredFormat: "compact",
instructions: (subject) => {
const domain = subject.slice(4);
return (
`1. Open your DNS registrar / nameserver console for ${domain}.\n` +
`2. Add a TXT record:\n` +
` Name: _kez.${domain}\n` +
` Value: (the kez:z1:… string on the right)\n` +
`3. Save. Propagation can take a few minutes.\n\n` +
`Notes:\n` +
`- DNS TXT records have a 255-byte segment limit. Most registrars (Cloudflare, Route 53, Gandi, etc.) handle long values by splitting into multiple segments automatically — just paste the whole string. If your registrar refuses, see the FAQ.\n` +
`- Verifiers resolve _kez.${domain} via DNS TXT lookups and decode the kez:z1: form to find your signed claim.`
);
},
},
{
key: "web",
label: "Your website",
identifierLabel: "HTTPS URL",
identifierPlaceholder: "https://tudisco.com",
toSubject: (s) => `web:${s.trim().replace(/\/$/, "")}`,
preferredFormat: "json",
instructions: (subject) => {
const base = subject.slice(4);
return (
`1. Save the JSON envelope on the right to a file named \`kez.json\`.\n` +
`2. Upload it to your server at: ${base}/.well-known/kez.json\n` +
`3. Make sure it's publicly fetchable (no auth, no robots.txt block).\n\n` +
`Verifiers fetch ${base}/.well-known/kez.json directly.`
);
},
},
{
key: "nostr",
label: "Nostr",
identifierLabel: "Your npub",
identifierPlaceholder: "npub1...",
toSubject: (s) => {
const raw = s.trim();
return raw.startsWith("nostr:") ? raw : `nostr:${raw}`;
},
preferredFormat: "markdown",
instructions: () =>
`Pick whichever is easiest — verifiers check all three:\n\n` +
`A. Add the markdown block on the right to your nostr profile bio\n` +
` (the "About" field on Damus, primal.net, etc.). One-time edit,\n` +
` anyone fetching your profile sees the proof.\n\n` +
`B. Post the markdown block as a normal nostr note (kind 1).\n` +
` Easiest if you're already active — just paste and publish.\n\n` +
`C. (Advanced) Publish a NIP-78 kind-30078 event with d='kez' and\n` +
` the JSON envelope as the event 'content'. Cleanest for tooling\n` +
` but needs a client that exposes kind-30078 creation.`,
},
{
key: "bluesky",
label: "Bluesky",
identifierLabel: "Bluesky handle",
identifierPlaceholder: "tudisco.bsky.social",
toSubject: (s) => `bluesky:${s.trim()}`,
preferredFormat: "markdown",
instructions: (subject) =>
`1. Open Bluesky and start a new post on your account (${subject.slice(8)}).\n` +
`2. Paste the markdown block on the right as the post body.\n` +
`3. Publish the post (publicly).\n\n` +
`Verifiers scan your public posts looking for the kez fence.`,
},
{
key: "ap",
label: "ActivityPub (Mastodon, Pleroma, …)",
identifierLabel: "ActivityPub handle",
identifierPlaceholder: "tudisco@mastodon.social",
toSubject: (s) => {
const raw = s.trim().replace(/^@/, "");
return `ap:@${raw}`;
},
preferredFormat: "markdown",
instructions: (subject) =>
`1. Go to your profile settings on your instance.\n` +
`2. Either paste the markdown block on the right into your "Profile fields" / metadata,\n` +
` or post it as a pinned post.\n\n` +
`Verifiers resolve ${subject} via WebFinger then fetch the actor JSON.`,
},
];
let step = $state<"pick" | "identifier" | "sign" | "publish" | "done">("pick");
let selected = $state<ChannelDef | null>(null);
let identifierInput = $state("");
let envelope = $state<SignedClaimEnvelope | null>(null);
let format = $state<Format>("compact");
let copied = $state(false);
/** Result of one-click "Publish via Nostr extension" — shown on the publish step. */
let nostrPublish = $state<
| { status: "idle" }
| { status: "pending" }
| { status: "ok"; result: PublishResult }
| { status: "error"; message: string }
>({ status: "idle" });
/** Outcome of mirroring this claim into the user's sigchain on the chain service. */
let sigchainSync = $state<
| { status: "idle" }
| { status: "pending" }
| { status: "ok"; seq: number; noop: boolean }
| { status: "error"; message: string }
>({ status: "idle" });
/** Re-evaluated each render; cheap (just a typeof check on window.nostr). */
const nip07Available = $derived(hasNip07());
/**
* Render the current envelope in the chosen format.
* Async because the compact form initializes the zstd WASM module on
* first use (subsequent calls are sync). Returns "" if no envelope.
*/
async function renderArtifact(
env: SignedClaimEnvelope,
fmt: Format,
): Promise<string> {
if (fmt === "markdown") return toMarkdown(env);
if (fmt === "json") return toPrettyJson(env);
return await toCompact(env);
}
onMount(() => {
if (!session.unlocked) push("/unlock");
});
function pickChannel(c: ChannelDef) {
selected = c;
format = c.preferredFormat; // DNS → compact, github → markdown, web → json
step = "identifier";
}
async function fillFromNostrExtension() {
try {
identifierInput = await getNostrNpub();
} catch (e) {
alert(`Couldn't read pubkey from nostr extension: ${(e as Error).message}`);
}
}
async function publishViaNostrExtension() {
if (!envelope) return;
nostrPublish = { status: "pending" };
try {
const markdown = toMarkdown(envelope);
const result = await publishKezClaimToNostr(markdown);
nostrPublish = { status: "ok", result };
} catch (e) {
nostrPublish = { status: "error", message: (e as Error).message };
}
}
function buildAndSign() {
if (!selected || !session.unlocked) return;
const subject = selected.toSubject(identifierInput);
if (!subject.includes(":") || subject.endsWith(":")) {
alert("Identifier looks empty — please fill in the field.");
return;
}
// UnlockedIdentity has {handle, server, primary, seed} — not the
// Ed25519Identity {seed, publicKey, identity} shape signClaim wants.
// Reconstruct from the seed so signer.identity is defined and
// ends up in payload.primary + signature.key.
const signer = identityFromSeed(session.unlocked.seed);
envelope = signClaim(signer, subject);
step = "publish";
}
async function copyArtifact() {
if (!envelope) return;
const text = await renderArtifact(envelope, format);
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 1500);
}
async function saveAndDone() {
if (!envelope || !selected || !session.unlocked) return;
const id = crypto.randomUUID();
const subject = envelope.payload.subject;
try {
// $state wraps `envelope` in a deep Proxy; structuredClone (used
// by idb-keyval) can't clone proxies and throws DataCloneError.
// $state.snapshot returns a plain, cloneable object.
await addClaim({
id,
envelope: $state.snapshot(envelope) as SignedClaimEnvelope,
channel: selected.key,
});
step = "done";
} catch (e) {
console.error("saveAndDone failed", e);
alert(`Failed to save claim: ${(e as Error).message}`);
return;
}
// Mirror the claim into the user's sigchain on the chain service: append
// a signed `add` event for this subject. Best-effort — the claim is
// already saved locally; if the chain service is unreachable we record
// the error and the user can retry from the Claims page later.
sigchainSync = { status: "pending" };
try {
const chainService = await resolveChainService(session.unlocked.handle);
const proofUrl =
nostrPublish.status === "ok" ? nostrPublish.result.evidence_url : undefined;
const result = await appendSubjectEvent({
seed: session.unlocked.seed,
chainService,
subject,
op: "add",
proofUrl,
});
await setSigchainSync(id, {
chain_service: result.chainService,
sigchain_seq: result.seq,
sigchain_status: "synced",
});
sigchainSync = { status: "ok", seq: result.seq, noop: result.noop };
} catch (e) {
await setSigchainSync(id, {
sigchain_status: "error",
sigchain_error: (e as Error).message,
});
sigchainSync = { status: "error", message: (e as Error).message };
}
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-text">Add a claim</h1>
<button
class="text-sm text-text-muted hover:text-text"
onclick={() => push("/claims")}
>
← Back to claims
</button>
</div>
<!-- Stepper -->
<ol class="flex gap-2 text-xs text-text-muted">
<li class={step === "pick" ? "font-semibold text-text" : ""}>1. Channel</li>
<li></li>
<li class={step === "identifier" ? "font-semibold text-text" : ""}>2. Identifier</li>
<li></li>
<li class={step === "publish" ? "font-semibold text-text" : ""}>3. Publish</li>
<li></li>
<li class={step === "done" ? "font-semibold text-text" : ""}>4. Done</li>
</ol>
{#if step === "pick"}
<div class="grid sm:grid-cols-2 gap-3">
{#each CHANNELS as c}
<button
class="text-left border border-border rounded-lg p-4 bg-surface hover:border-accent-dim transition"
onclick={() => pickChannel(c)}
>
<p class="font-semibold text-text">{c.label}</p>
<p class="text-xs text-text-muted mt-1 font-mono">{c.key}:&lt;&gt;</p>
</button>
{/each}
</div>
{/if}
{#if step === "identifier" && selected}
<form
class="space-y-4"
onsubmit={(e) => { e.preventDefault(); buildAndSign(); }}
>
<div>
<label class="block text-sm font-medium text-text-secondary" for="ident">
{selected.identifierLabel}
</label>
<input
id="ident"
type="text"
bind:value={identifierInput}
placeholder={selected.identifierPlaceholder}
class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono"
autocomplete="off"
/>
{#if selected.key === "nostr" && nip07Available}
<button
type="button"
class="mt-2 inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-accent/40 bg-accent/10 text-accent rounded-md hover:bg-accent/20"
onclick={fillFromNostrExtension}
>
⚡ Use my nostr extension
</button>
<span class="ml-2 text-xs text-text-muted">Reads your pubkey via NIP-07 — no copy/paste.</span>
{/if}
{#if identifierInput.trim()}
<p class="mt-1 text-xs text-text-muted">
Subject will be: <code class="bg-elevated px-1 rounded">{selected.toSubject(identifierInput)}</code>
</p>
{/if}
</div>
<div class="flex gap-2">
<button
type="button"
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={() => { step = "pick"; selected = null; }}
>
Back
</button>
<button
type="submit"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={identifierInput.trim().length === 0}
>
Sign claim
</button>
</div>
</form>
{/if}
{#if step === "publish" && envelope && selected}
<div class="grid lg:grid-cols-2 gap-6">
<section class="space-y-3">
<h2 class="text-sm font-semibold text-text-secondary uppercase tracking-wide">
1. Publish on {selected.label}
</h2>
<pre class="whitespace-pre-wrap text-sm bg-elevated border border-border rounded p-4 leading-relaxed">{selected.instructions(envelope.payload.subject)}</pre>
</section>
<section class="space-y-3">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-sm font-semibold text-text-secondary uppercase tracking-wide flex-1">
2. Copy this:
</h2>
<div class="flex border border-border rounded overflow-hidden text-xs">
<button
class={`px-2 py-1 ${format === "compact" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
onclick={() => (format = "compact")}
title="kez:z1: — zstd + base64url. Fits in tight places like DNS TXT records, QR codes, chat messages."
>compact</button>
<button
class={`px-2 py-1 border-l border-border ${format === "markdown" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
onclick={() => (format = "markdown")}
title="Human-readable; embeds the JSON inside a ```kez fence. Best for gists and README files."
>markdown</button>
<button
class={`px-2 py-1 border-l border-border ${format === "json" ? "bg-accent text-accent-contrast" : "bg-surface text-text-secondary hover:bg-elevated"}`}
onclick={() => (format = "json")}
title="Raw envelope JSON. Best for .well-known/kez.json and developer tooling."
>JSON</button>
</div>
</div>
{#await renderArtifact(envelope, format)}
<pre class="text-xs bg-accent text-text rounded p-4 font-mono leading-relaxed max-h-96 min-h-32 flex items-center justify-center text-text-muted">Computing…</pre>
{:then text}
<pre class="text-xs bg-accent text-text rounded p-4 overflow-x-auto font-mono leading-relaxed max-h-96 overflow-y-auto whitespace-pre-wrap break-all">{text}</pre>
{#if format === "compact"}
<p class="text-xs text-text-muted">
{text.length} chars · zstd-compressed signed envelope, base64url-encoded.
</p>
{/if}
{:catch e}
<pre class="text-xs bg-danger/10 border border-danger/40 text-danger rounded p-4">Error: {e.message}</pre>
{/await}
<button
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={copyArtifact}
>
{copied ? "✓ Copied" : "Copy to clipboard"}
</button>
</section>
</div>
{#if selected.key === "nostr" && nip07Available}
<div class="border border-accent/40 bg-accent/10 rounded-lg p-4">
<p class="text-sm font-semibold text-accent">⚡ One-click publish via your nostr extension</p>
<p class="mt-1 text-xs text-accent">
Wraps the markdown block in a normal nostr post (kind 1), asks
your extension to sign it, and broadcasts to the relay pool.
Verifiers (web + Rust CLI) will pick it up automatically.
</p>
<div class="mt-3 flex items-center gap-3">
<button
class="px-3 py-1.5 text-sm bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
onclick={publishViaNostrExtension}
disabled={nostrPublish.status === "pending"}
>
{nostrPublish.status === "pending" ? "Publishing…" : "Publish to nostr"}
</button>
{#if nostrPublish.status === "ok"}
<span class="text-xs text-verified">
✓ Posted to {nostrPublish.result.ok.length} relay(s).
<a
href={nostrPublish.result.evidence_url}
target="_blank"
rel="noopener noreferrer"
class="underline"
>view on njump.me</a>
</span>
{:else if nostrPublish.status === "error"}
<span class="text-xs text-danger">{nostrPublish.message}</span>
{/if}
</div>
{#if nostrPublish.status === "ok" && nostrPublish.result.failed.length > 0}
<p class="mt-2 text-xs text-warning">
{nostrPublish.result.failed.length} relay(s) didn't ack:
{nostrPublish.result.failed.map((f) => f.relay).join(", ")}
</p>
{/if}
</div>
{/if}
<div class="flex gap-2 pt-4 border-t border-border">
<button
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={() => { step = "identifier"; }}
>
Back
</button>
<button
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim"
onclick={saveAndDone}
>
Save claim
</button>
</div>
{/if}
{#if step === "done" && envelope}
<div class="border border-verified/40 bg-verified/10 rounded-lg p-6">
<p class="text-lg font-semibold text-verified">✓ Claim saved</p>
<p class="mt-2 text-sm text-verified">
You signed a claim for
<code class="font-mono">{envelope.payload.subject}</code>.
Once you've published the proof on that channel, come back to the
Claims page and mark it published.
</p>
<!-- Chain-service (sigchain) mirror status -->
<div class="mt-3 text-sm">
{#if sigchainSync.status === "pending"}
<p class="text-text-secondary">⏳ Updating your sigchain on the chain service…</p>
{:else if sigchainSync.status === "ok"}
<p class="text-verified">
{#if sigchainSync.noop}
⛓ Already on your sigchain (seq {sigchainSync.seq}) — nothing to add.
{:else}
⛓ Sigchain updated — added at seq {sigchainSync.seq} on the chain service.
{/if}
</p>
{:else if sigchainSync.status === "error"}
<p class="text-warning">
⚠ Couldn't update your sigchain on the chain service:
{sigchainSync.message}. The claim is saved locally — retry the
sigchain sync from the Claims page.
</p>
{/if}
</div>
<div class="mt-4 flex gap-2">
<a
href="#/claims"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim no-underline"
>
Back to claims
</a>
<button
class="px-4 py-2 border border-verified/40 bg-surface rounded-md text-verified hover:bg-elevated"
onclick={() => {
step = "pick";
selected = null;
identifierInput = "";
envelope = null;
nostrPublish = { status: "idle" };
sigchainSync = { status: "idle" };
}}
>
Add another
</button>
</div>
</div>
{/if}
</div>