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:
parent
c133be0589
commit
a8036cc392
119
kez-chat/web/src/lib/nip07.ts
Normal file
119
kez-chat/web/src/lib/nip07.ts
Normal 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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -11,6 +11,12 @@
|
|||||||
} from "../lib/kez.js";
|
} from "../lib/kez.js";
|
||||||
import { addClaim } from "../lib/claims-store.js";
|
import { addClaim } from "../lib/claims-store.js";
|
||||||
import { session } from "../lib/store.svelte.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 ChannelKey = "github" | "dns" | "web" | "nostr" | "bluesky" | "ap";
|
||||||
|
|
||||||
@ -140,6 +146,16 @@
|
|||||||
let envelope = $state<SignedClaimEnvelope | null>(null);
|
let envelope = $state<SignedClaimEnvelope | null>(null);
|
||||||
let format = $state<Format>("compact");
|
let format = $state<Format>("compact");
|
||||||
let copied = $state(false);
|
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.
|
* Render the current envelope in the chosen format.
|
||||||
@ -165,6 +181,26 @@
|
|||||||
step = "identifier";
|
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() {
|
function buildAndSign() {
|
||||||
if (!selected || !session.unlocked) return;
|
if (!selected || !session.unlocked) return;
|
||||||
const subject = selected.toSubject(identifierInput);
|
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"
|
class="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md font-mono"
|
||||||
autocomplete="off"
|
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()}
|
{#if identifierInput.trim()}
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
Subject will be: <code class="bg-gray-100 px-1 rounded">{selected.toSubject(identifierInput)}</code>
|
Subject will be: <code class="bg-gray-100 px-1 rounded">{selected.toSubject(identifierInput)}</code>
|
||||||
@ -342,6 +388,45 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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">
|
<div class="flex gap-2 pt-4 border-t border-gray-200">
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user