feat(kez-chat/web): NIP-07 extension support — autofill npub + one-click publish

If a NIP-07 nostr extension (Alby, nos2x, Flamingo, …) is loaded into
the page, the Add Claim flow detects it and offers two shortcuts:

  • Identifier step (nostr): " Use my nostr extension" button reads
    window.nostr.getPublicKey() and fills the npub via nip19.npubEncode
    — no copy/paste from Damus/primal needed.

  • Publish step (nostr): " One-click publish via your nostr extension"
    wraps the kez markdown block in a kind-1 nostr post, asks the
    extension to sign it (the user's nostr key never enters this app —
    the extension gates each signature behind its own UX), and
    broadcasts to the same 5-relay pool the verifier reads from.
    Result UI shows ✓ N relays ok + an njump.me evidence link, and
    lists any relay that didn't ack.

New module lib/nip07.ts holds the wrapper — hasNip07, getNostrNpub,
publishKezClaimToNostr — so future flows (Dashboard, sigchain push)
can reuse the same plumbing.

Initial JS: 130→134 KB. nostr-tools stays in its own dynamic-import
chunk so the extension code only loads when the user clicks one of
these buttons.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-25 15:18:28 -06:00
parent c133be0589
commit a8036cc392
2 changed files with 204 additions and 0 deletions

View File

@ -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<string>; // 64-char hex
signEvent(event: UnsignedNostrEvent): Promise<SignedNostrEvent>;
};
}
}
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<string> {
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<PublishResult> {
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}`,
};
}

View File

@ -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<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" });
/** 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}
<button
type="button"
class="mt-2 inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-purple-300 bg-purple-50 text-purple-800 rounded-md hover:bg-purple-100"
onclick={fillFromNostrExtension}
>
⚡ Use my nostr extension
</button>
<span class="ml-2 text-xs text-gray-500">Reads your pubkey via NIP-07 — no copy/paste.</span>
{/if}
{#if identifierInput.trim()}
<p class="mt-1 text-xs text-gray-500">
Subject will be: <code class="bg-gray-100 px-1 rounded">{selected.toSubject(identifierInput)}</code>
@ -342,6 +388,45 @@
</section>
</div>
{#if selected.key === "nostr" && nip07Available}
<div class="border border-purple-200 bg-purple-50 rounded-lg p-4">
<p class="text-sm font-semibold text-purple-900">⚡ One-click publish via your nostr extension</p>
<p class="mt-1 text-xs text-purple-800">
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-purple-700 text-white rounded-md hover:bg-purple-800 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-green-800">
✓ 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-red-800">{nostrPublish.message}</span>
{/if}
</div>
{#if nostrPublish.status === "ok" && nostrPublish.result.failed.length > 0}
<p class="mt-2 text-xs text-amber-800">
{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-gray-200">
<button
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"