diff --git a/kez-chat/web/src/lib/nip07.ts b/kez-chat/web/src/lib/nip07.ts new file mode 100644 index 0000000..c1493d9 --- /dev/null +++ b/kez-chat/web/src/lib/nip07.ts @@ -0,0 +1,119 @@ +// Thin wrapper around the NIP-07 browser extension API +// (window.nostr — Alby, nos2x, Flamingo, etc.). +// +// We use the extension for two convenience flows on the Add Claim page: +// 1. Read the user's nostr pubkey so they don't have to paste their +// npub (`getNostrPubkey`). +// 2. After the KEZ envelope is signed, wrap it in a kind-1 nostr event +// and have the extension sign + broadcast it to a relay pool +// (`publishKezClaimToNostr`). +// +// The user's nostr key never enters this app — the extension holds it +// and gates each signature behind its own UX (passphrase prompt etc.). + +declare global { + interface Window { + nostr?: { + getPublicKey(): Promise; // 64-char hex + signEvent(event: UnsignedNostrEvent): Promise; + }; + } +} + +interface UnsignedNostrEvent { + kind: number; + content: string; + tags: string[][]; + created_at: number; + pubkey?: string; +} + +interface SignedNostrEvent extends UnsignedNostrEvent { + id: string; + sig: string; + pubkey: string; +} + +/** True when a NIP-07 extension is loaded into this page. */ +export function hasNip07(): boolean { + return typeof window !== "undefined" && !!window.nostr?.getPublicKey; +} + +/** + * Read the user's nostr pubkey via the extension and return it as an + * `npub1…` bech32 string ready to paste into the identifier field. + */ +export async function getNostrNpub(): Promise { + if (!window.nostr) throw new Error("No NIP-07 extension detected"); + const hex = await window.nostr.getPublicKey(); + const { nip19 } = await import("nostr-tools"); + return nip19.npubEncode(hex); +} + +/** + * Same five-relay pool the verifier uses (keep them in sync so a freshly + * published event lands on relays we'll actually check during verify). + */ +const RELAYS = [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + "wss://relay.snort.social", + "wss://nostr.wine", +]; + +export interface PublishResult { + event_id: string; + /** Relays that accepted (or at least didn't reject). */ + ok: string[]; + /** Relays that explicitly rejected or never responded. */ + failed: { relay: string; error: string }[]; + /** njump.me link to the event for evidence. */ + evidence_url: string; +} + +/** + * Wrap the kez markdown block in a kind-1 nostr post, ask the extension + * to sign it, then broadcast to the relay pool. The kez fence inside the + * post is what verifiers pick up. + * + * Why kind 1 (not 30078)? Because the user just clicked a button — they + * almost certainly want this visible on their feed too. A normal post + * is both human-readable and machine-verifiable. + */ +export async function publishKezClaimToNostr( + markdown: string, +): Promise { + if (!window.nostr) throw new Error("No NIP-07 extension detected"); + + const unsigned: UnsignedNostrEvent = { + kind: 1, + content: markdown, + tags: [["t", "kez"]], + created_at: Math.floor(Date.now() / 1000), + }; + const signed = await window.nostr.signEvent(unsigned); + + // Broadcast in parallel — collect per-relay outcomes. + const { SimplePool } = await import("nostr-tools"); + const pool = new SimplePool(); + const results = await Promise.allSettled( + pool.publish(RELAYS, signed as never), + ); + pool.close(RELAYS); + + const ok: string[] = []; + const failed: PublishResult["failed"] = []; + results.forEach((r, i) => { + const relay = RELAYS[i]; + if (r.status === "fulfilled") ok.push(relay); + else failed.push({ relay, error: String(r.reason) }); + }); + + return { + event_id: signed.id, + ok, + failed, + evidence_url: `https://njump.me/${signed.id}`, + }; +} diff --git a/kez-chat/web/src/routes/AddClaim.svelte b/kez-chat/web/src/routes/AddClaim.svelte index f927393..08d311c 100644 --- a/kez-chat/web/src/routes/AddClaim.svelte +++ b/kez-chat/web/src/routes/AddClaim.svelte @@ -11,6 +11,12 @@ } from "../lib/kez.js"; import { addClaim } from "../lib/claims-store.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"; @@ -140,6 +146,16 @@ let envelope = $state(null); let format = $state("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" }); + + /** Re-evaluated each render; cheap (just a typeof check on window.nostr). */ + const nip07Available = $derived(hasNip07()); /** * Render the current envelope in the chosen format. @@ -165,6 +181,26 @@ 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); @@ -261,6 +297,16 @@ class="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md font-mono" autocomplete="off" /> + {#if selected.key === "nostr" && nip07Available} + + Reads your pubkey via NIP-07 — no copy/paste. + {/if} {#if identifierInput.trim()}

Subject will be: {selected.toSubject(identifierInput)} @@ -342,6 +388,45 @@ + {#if selected.key === "nostr" && nip07Available} +

+

⚡ One-click publish via your nostr extension

+

+ 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. +

+
+ + {#if nostrPublish.status === "ok"} + + ✓ Posted to {nostrPublish.result.ok.length} relay(s). + view on njump.me + + {:else if nostrPublish.status === "error"} + ✗ {nostrPublish.message} + {/if} +
+ {#if nostrPublish.status === "ok" && nostrPublish.result.failed.length > 0} +

+ {nostrPublish.result.failed.length} relay(s) didn't ack: + {nostrPublish.result.failed.map((f) => f.relay).join(", ")} +

+ {/if} +
+ {/if} +