250 lines
7.9 KiB
TypeScript
250 lines
7.9 KiB
TypeScript
// Two signing paths: NIP-07 browser extension and NIP-46 remote signer
|
|
// (covers Primal app + Amber via bunker:// URI). The active signer is held
|
|
// in module state — there's only one logged-in user at a time.
|
|
|
|
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
|
|
import type { Filter } from "nostr-tools/filter";
|
|
import { SimplePool } from "nostr-tools/pool";
|
|
import { readonly, ref } from "vue";
|
|
|
|
export type SignerKind = "extension" | "bunker";
|
|
|
|
export type Signer = {
|
|
kind: SignerKind;
|
|
getPublicKey(): Promise<string>;
|
|
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
|
};
|
|
|
|
const activeSigner = ref<Signer | null>(null);
|
|
const NOSTR_CONNECT_RELAYS = ["wss://relay.primal.net"];
|
|
const NOSTR_CONNECT_TIMEOUT_MS = 120_000;
|
|
const NOSTR_CONNECT_PENDING_KEY = "gashboard.nostrconnect.pending";
|
|
const pool = new SimplePool();
|
|
|
|
export function getActiveSigner(): Signer | null {
|
|
return activeSigner.value;
|
|
}
|
|
|
|
export function hasActiveSigner(): boolean {
|
|
return activeSigner.value !== null;
|
|
}
|
|
|
|
export function clearSigner(): void {
|
|
activeSigner.value = null;
|
|
}
|
|
|
|
export const activeSignerState = readonly(activeSigner);
|
|
|
|
export async function loginWithExtension(): Promise<string> {
|
|
if (!window.nostr) {
|
|
throw new Error("No NIP-07 extension found. Try Alby, nos2x, or Primal extension.");
|
|
}
|
|
const ext = window.nostr;
|
|
const pubkey = await ext.getPublicKey();
|
|
activeSigner.value = {
|
|
kind: "extension",
|
|
getPublicKey: async () => pubkey,
|
|
signEvent: async (template: EventTemplate) =>
|
|
(await ext.signEvent({
|
|
kind: template.kind,
|
|
created_at: template.created_at,
|
|
tags: template.tags,
|
|
content: template.content,
|
|
})) as NostrEvent,
|
|
};
|
|
return pubkey;
|
|
}
|
|
|
|
export async function loginWithBunker(bunkerUri: string): Promise<string> {
|
|
// Lazy-load applesauce-signers — only pulled when bunker login is attempted
|
|
// so the dashboard works for users who only use NIP-07.
|
|
const mod = await import("applesauce-signers");
|
|
const Ctor =
|
|
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
|
|
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
|
|
if (!Ctor) {
|
|
throw new Error("applesauce-signers does not expose NostrConnectSigner");
|
|
}
|
|
const fromUri = Ctor.fromBunkerURI ?? Ctor.fromURI;
|
|
if (!fromUri) {
|
|
throw new Error("NostrConnectSigner has no fromBunkerURI / fromURI helper");
|
|
}
|
|
const signer = await fromUri.call(Ctor, bunkerUri);
|
|
const pubkey: string = await signer.getPublicKey();
|
|
activeSigner.value = {
|
|
kind: "bunker",
|
|
getPublicKey: async () => pubkey,
|
|
signEvent: async (template: EventTemplate) =>
|
|
(await signer.signEvent(template)) as NostrEvent,
|
|
};
|
|
return pubkey;
|
|
}
|
|
|
|
export async function loginWithRemoteApp(): Promise<string> {
|
|
return connectRemoteApp({ openApp: true });
|
|
}
|
|
|
|
export async function resumeRemoteAppLogin(): Promise<string> {
|
|
return connectRemoteApp({ openApp: false });
|
|
}
|
|
|
|
export function hasPendingRemoteAppLogin(): boolean {
|
|
return !!sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
|
|
}
|
|
|
|
async function connectRemoteApp(opts: { openApp: boolean }): Promise<string> {
|
|
const mod = await import("applesauce-signers");
|
|
const NostrConnectSigner =
|
|
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
|
|
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
|
|
const PrivateKeySigner =
|
|
(mod as { PrivateKeySigner?: any }).PrivateKeySigner ??
|
|
((mod as { default?: { PrivateKeySigner?: any } }).default?.PrivateKeySigner);
|
|
if (!NostrConnectSigner || !PrivateKeySigner) {
|
|
throw new Error("Nostr Connect signer support is unavailable");
|
|
}
|
|
|
|
const pending = loadPendingRemoteLogin();
|
|
const clientSigner = pending
|
|
? new PrivateKeySigner(new Uint8Array(pending.key))
|
|
: new PrivateKeySigner();
|
|
const signer = new NostrConnectSigner({
|
|
relays: pending?.relays ?? NOSTR_CONNECT_RELAYS,
|
|
signer: clientSigner,
|
|
...(pending ? { secret: pending.secret } : {}),
|
|
subscriptionMethod: subscribeToRelays,
|
|
publishMethod: publishToRelays,
|
|
});
|
|
|
|
const permissions =
|
|
NostrConnectSigner.buildSigningPermissions?.([27235]) ?? ["sign_event:27235"];
|
|
if (!pending) {
|
|
savePendingRemoteLogin({
|
|
key: Array.from(clientSigner.key),
|
|
secret: signer.secret,
|
|
relays: NOSTR_CONNECT_RELAYS,
|
|
});
|
|
}
|
|
|
|
if (opts.openApp) {
|
|
const uri = withCallback(
|
|
signer.getNostrConnectURI({
|
|
name: "gashboard",
|
|
url: window.location.origin,
|
|
permissions,
|
|
}),
|
|
);
|
|
openSignerApp(uri);
|
|
}
|
|
|
|
const abort = new AbortController();
|
|
const timeout = window.setTimeout(() => abort.abort(), NOSTR_CONNECT_TIMEOUT_MS);
|
|
try {
|
|
await signer.waitForSigner(abort.signal);
|
|
if (!signer.remote) throw new Error("Remote signer did not complete the connection");
|
|
const pubkey: string = await signer.getPublicKey();
|
|
activeSigner.value = {
|
|
kind: "bunker",
|
|
getPublicKey: async () => pubkey,
|
|
signEvent: async (template: EventTemplate) =>
|
|
(await signer.signEvent(template)) as NostrEvent,
|
|
};
|
|
clearPendingRemoteLogin();
|
|
return pubkey;
|
|
} finally {
|
|
window.clearTimeout(timeout);
|
|
if (!signer.isConnected) await signer.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
type PendingRemoteLogin = {
|
|
key: number[];
|
|
secret: string;
|
|
relays: string[];
|
|
};
|
|
|
|
function loadPendingRemoteLogin(): PendingRemoteLogin | null {
|
|
try {
|
|
const raw = sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as PendingRemoteLogin;
|
|
if (!Array.isArray(parsed.key) || !parsed.secret || !Array.isArray(parsed.relays)) return null;
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function savePendingRemoteLogin(pending: PendingRemoteLogin): void {
|
|
sessionStorage.setItem(NOSTR_CONNECT_PENDING_KEY, JSON.stringify(pending));
|
|
}
|
|
|
|
function clearPendingRemoteLogin(): void {
|
|
sessionStorage.removeItem(NOSTR_CONNECT_PENDING_KEY);
|
|
}
|
|
|
|
function withCallback(uri: string): string {
|
|
const separator = uri.includes("?") ? "&" : "?";
|
|
return `${uri}${separator}callback=${encodeURIComponent(`${window.location.origin}/auth/nostr-callback`)}`;
|
|
}
|
|
|
|
function openSignerApp(uri: string): void {
|
|
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
|
navigator.userAgent,
|
|
);
|
|
if (isMobile) {
|
|
window.location.href = uri;
|
|
return;
|
|
}
|
|
const opened = window.open(uri, "_blank", "noopener,noreferrer");
|
|
if (!opened) window.location.href = uri;
|
|
}
|
|
|
|
function subscribeToRelays(relays: string[], filters: Filter[]): AsyncIterable<NostrEvent | string> {
|
|
return {
|
|
[Symbol.asyncIterator]() {
|
|
const queue: Array<NostrEvent | string> = [];
|
|
let wake: (() => void) | null = null;
|
|
let closed = false;
|
|
const sub = pool.subscribeMany(relays, filters, {
|
|
onevent(event) {
|
|
queue.push(event as NostrEvent);
|
|
wake?.();
|
|
},
|
|
onclose(reasons) {
|
|
for (const reason of reasons) queue.push(reason);
|
|
closed = true;
|
|
wake?.();
|
|
},
|
|
});
|
|
|
|
return {
|
|
async next(): Promise<IteratorResult<NostrEvent | string>> {
|
|
while (!queue.length && !closed) {
|
|
await new Promise<void>((resolve) => {
|
|
wake = resolve;
|
|
});
|
|
wake = null;
|
|
}
|
|
const value = queue.shift();
|
|
if (value) return { value, done: false };
|
|
return { value: undefined, done: true };
|
|
},
|
|
async return(): Promise<IteratorResult<NostrEvent | string>> {
|
|
closed = true;
|
|
sub.close();
|
|
wake?.();
|
|
return { value: undefined, done: true };
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
async function publishToRelays(relays: string[], event: NostrEvent): Promise<void> {
|
|
const results = await Promise.allSettled(pool.publish(relays, event));
|
|
if (!results.some((r) => r.status === "fulfilled")) {
|
|
throw new Error("Could not publish Nostr Connect request to relay");
|
|
}
|
|
}
|