mirror of
https://github.com/believethehype/nostrdvm.git
synced 2025-03-17 13:21:48 +01:00
attempt for nostr-ignition
This commit is contained in:
parent
b68c4a4b42
commit
f8ad2f19bd
@ -17,8 +17,10 @@
|
||||
"bech32": "^2.0.0",
|
||||
"bootstrap": "^5.3.2",
|
||||
"daisyui": "^4.6.0",
|
||||
"events": "^3.3.0",
|
||||
"mini-toastr": "^0.8.1",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"nostr-ignition": "^0.0.5",
|
||||
"nostr-tools": "^2.1.5",
|
||||
"vue": "^3.4.15",
|
||||
"vue-notifications": "^1.0.2",
|
||||
"vue3-easy-data-table": "^1.5.47",
|
||||
|
@ -31,21 +31,55 @@
|
||||
<div tabIndex={0} role="button" class="v-Button" >Sign in</div>
|
||||
<div tabIndex={0} className="dropdown-content -start-44 z-[1] horizontal card card-compact w-64 p-2 shadow bg-primary text-primary-content">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Nip07 Login</h3>
|
||||
<p>Use a Browser Nip07 Extension like getalby or nos2x to login or use Amber on Android</p>
|
||||
<h3 className="card-title">Login</h3>
|
||||
<p>Use a Browser Nip07 Extension like getalby or nos2x or Nsecbunker to login or use Amber on Android</p>
|
||||
<button className="btn" @click="sign_in_nip07()">Browser Extension</button>
|
||||
<!-- <button className="btn" @click="sign_in_nip46()">NsecBunker</button> Not working yet-->
|
||||
<button className="btn" @click="getWindowNostr()">Nostraddress</button>
|
||||
|
||||
<template v-if="supports_android_signer">
|
||||
<button className="btn" @click="sign_in_amber()">Amber Sign in</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="checkConnected">
|
||||
<h2>🎊 Connected successfully! 🎊</h2>
|
||||
<img src="https://i.nostr.build/a4OQ.gif" alt="thumbs-up" />
|
||||
<div id="npubMessage">
|
||||
The npub of your account is:
|
||||
<div id="newNostrNpub"></div>
|
||||
</div>
|
||||
<div id="sign" style="margin-top: 2rem">
|
||||
<h2>Want to try signing a note? Don't worry, we won't publish it.</h2>
|
||||
<pre id="noteCode"></pre>
|
||||
<button
|
||||
style="display: block; font-size: 1.25rem; padding: 5px 8px; margin: 0 auto"
|
||||
onclick="NostrIgnition.signEvent(JSON.parse(document.getElementById('noteCode').innerText))"
|
||||
>
|
||||
Sign "Hello World!"
|
||||
</button>
|
||||
<h2 style="margin-top: 4rem">Or, open your browser console and try pinging the bunker.</h2>
|
||||
<button
|
||||
style="display: block; font-size: 1.25rem; padding: 5px 8px; margin: 0 auto"
|
||||
onclick="NostrIgnition.ping()"
|
||||
id="pingButton"
|
||||
>
|
||||
Ping Bunker
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Nip89></Nip89>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
|
||||
import {
|
||||
loadWasmAsync,
|
||||
Client,
|
||||
@ -54,7 +88,17 @@ import {
|
||||
Filter,
|
||||
initLogger,
|
||||
LogLevel,
|
||||
Timestamp, Keys, NostrDatabase, ClientBuilder, ClientZapper, Alphabet, SingleLetterTag, Options, Duration, PublicKey
|
||||
Timestamp,
|
||||
Keys,
|
||||
NostrDatabase,
|
||||
ClientBuilder,
|
||||
ClientZapper,
|
||||
Alphabet,
|
||||
SingleLetterTag,
|
||||
Options,
|
||||
Duration,
|
||||
PublicKey,
|
||||
Nip46Signer
|
||||
} from "@rust-nostr/nostr-sdk";
|
||||
import VueNotifications from "vue-notifications";
|
||||
import store from '../store';
|
||||
@ -62,14 +106,16 @@ import Nip89 from "@/components/Nip89.vue";
|
||||
import miniToastr from "mini-toastr";
|
||||
import deadnip89s from "@/components/data/deadnip89s.json";
|
||||
import amberSignerService from "./android-signer/AndroidSigner";
|
||||
|
||||
import {useDark, useToggle} from "@vueuse/core";
|
||||
import {ref} from "vue";
|
||||
const isDark = useDark();
|
||||
//const toggleDark = useToggle(isDark);
|
||||
|
||||
|
||||
import NostrIgnition from "./nostr-ignition/NostrIgnition"
|
||||
|
||||
let nip89dvms = []
|
||||
let logger = true
|
||||
let logger = false
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
@ -79,7 +125,40 @@ export default {
|
||||
supports_android_signer: false,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
|
||||
async mounted() {
|
||||
|
||||
|
||||
await NostrIgnition.init({
|
||||
appName: "Noogle"})
|
||||
|
||||
const checkConnected = ref(false);
|
||||
const interval = setInterval(() => {
|
||||
if (NostrIgnition.connected()) {
|
||||
checkConnected.value = true
|
||||
console.log("connected")
|
||||
console.log("IGNITION " + NostrIgnition.remoteNpub())
|
||||
//document.getElementById("newNostrNpub").innerText = NostrIgnition.remoteNpub();
|
||||
//document.getElementById("connectionSuccess").style.display = "flex";
|
||||
|
||||
const event = {
|
||||
content: "Hello World!",
|
||||
pubkey: NostrIgnition.remotePubkey(),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
document.getElementById("noteCode").innerText = JSON.stringify(event, null, 4);
|
||||
clearInterval(interval);
|
||||
}
|
||||
console.log("looping")
|
||||
}, 1000);
|
||||
|
||||
|
||||
|
||||
try{
|
||||
if (amberSignerService.supported) {
|
||||
this.supports_android_signer = true;
|
||||
@ -89,14 +168,15 @@ export default {
|
||||
{
|
||||
await this.sign_in_nip07()
|
||||
}
|
||||
/* else if (localStorage.getItem('nostr-key-method') === 'nip46')
|
||||
{
|
||||
await this.sign_in_nip46()
|
||||
}*/
|
||||
|
||||
else if (localStorage.getItem('nostr-key-method') === 'android-signer')
|
||||
{
|
||||
let key = ""
|
||||
if (localStorage.getItem('nostr-key') !== ""){
|
||||
key = localStorage.getItem('nostr-key')
|
||||
}
|
||||
await this.sign_in_amber(key)
|
||||
|
||||
await this.sign_in_amber()
|
||||
}
|
||||
else {
|
||||
await this.sign_in_anon()
|
||||
@ -109,8 +189,15 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
methods: {
|
||||
|
||||
getWindowNostr() {
|
||||
console.log("I try to call window.nostr,but nothing happens")
|
||||
return window.nostr
|
||||
},
|
||||
|
||||
toggleDark(){
|
||||
isDark.value = !isDark.value
|
||||
useToggle(isDark);
|
||||
@ -122,6 +209,7 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async sign_in_anon() {
|
||||
try {
|
||||
await loadWasmAsync();
|
||||
@ -165,6 +253,77 @@ export default {
|
||||
console.log("Client connected")
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
async sign_in_nip46() {
|
||||
|
||||
try {
|
||||
|
||||
await loadWasmAsync();
|
||||
|
||||
|
||||
|
||||
let connectionstring = ""
|
||||
if (localStorage.getItem('nostr-key') !== "" && localStorage.getItem('nostr-key').startsWith("nsecbunker://") ){
|
||||
connectionstring = localStorage.getItem('nostr-key')
|
||||
}
|
||||
|
||||
if (connectionstring === ""){
|
||||
//ADD DEFAULT TEST STRING FOR NOW, USE USER INPUT LATER
|
||||
connectionstring = "nsecbunker://npub1ffske30n349f7z3sccn6n90f9dxxqhcy5n4cgpq32355ka2ye6ls7sa6t4#7a53c7292aa6a8f731cd6fcc15b396213c6a7b0448f9e8994b2479f8832c029f?relay=wss://relay.nsecbunker.com"
|
||||
}
|
||||
|
||||
if (connectionstring.startsWith("nsecbunker://")){
|
||||
connectionstring = connectionstring.replace("nsecbunker://", "")
|
||||
let split = connectionstring.split("?relay=")
|
||||
let relay_url = split[1]
|
||||
let split2 = split[0].split("#")
|
||||
let publickey = Keys.fromPkStr(split2[0]).publicKey
|
||||
let app_keys = Keys.fromSkStr(split2[1])
|
||||
|
||||
|
||||
let nip46_signer = new Nip46Signer(relay_url, app_keys, publickey) ;
|
||||
try{
|
||||
this.signer = ClientSigner.nip46(nip46_signer);
|
||||
console.log("SIGNER: " + this.signer)
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
this.signer = ClientSigner.keys(Keys.generate())
|
||||
}
|
||||
|
||||
//let zapper = ClientZapper.webln()
|
||||
let opts = new Options().waitForSend(false).connectionTimeout(Duration.fromSecs(5));
|
||||
let client = new ClientBuilder().signer(this.signer).opts(opts).build()
|
||||
|
||||
await client.addRelay(relay_url)
|
||||
for (const relay of store.state.relays){
|
||||
await client.addRelay(relay);
|
||||
}
|
||||
|
||||
const pubkey = await nip46_signer.signerPublicKey()
|
||||
console.log("PUBKEY : " + pubkey.toBech32())
|
||||
await client.connect();
|
||||
|
||||
store.commit('set_client', client)
|
||||
store.commit('set_pubkey', pubkey)
|
||||
store.commit('set_hasEventListener', false)
|
||||
localStorage.setItem('nostr-key-method', "nip46")
|
||||
localStorage.setItem('nostr-key', connectionstring)
|
||||
console.log("Client connected")
|
||||
await this.get_user_info(pubkey)
|
||||
//miniToastr.showMessage("Login successful!", "Logged in as " + this.current_user, VueNotifications.types.success)
|
||||
|
||||
|
||||
}
|
||||
else {
|
||||
miniToastr.showMessage("Invalid Nsecbunker url")
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
@ -242,6 +401,11 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
let key = ""
|
||||
if (localStorage.getItem('nostr-key') !== ""){
|
||||
key = localStorage.getItem('nostr-key')
|
||||
}
|
||||
|
||||
if (!amberSignerService.supported) {
|
||||
alert("android signer not supported")
|
||||
return;
|
||||
@ -364,7 +528,7 @@ export default {
|
||||
this.current_user = ""
|
||||
localStorage.setItem('nostr-key-method', "anon")
|
||||
localStorage.setItem('nostr-key', "")
|
||||
await this.state.client.shutdown();
|
||||
//await this.state.client.shutdown();
|
||||
await this.sign_in_anon()
|
||||
}
|
||||
},
|
||||
|
@ -1,10 +1,9 @@
|
||||
// taken from https://github.com/hzrd149/nostrudel
|
||||
|
||||
import { nip19, verifySignature } from "nostr-tools";
|
||||
import { nip19, verifyEvent } from "nostr-tools";
|
||||
import createDefer, { Deferred } from "./classes/deffered";
|
||||
import { getPubkeyFromDecodeResult, isHexKey } from "./helpers/nip19";
|
||||
import { NostrEvent } from "./types/nostr-event";
|
||||
|
||||
export function createGetPublicKeyIntent() {
|
||||
return `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end`;
|
||||
}
|
||||
@ -67,7 +66,7 @@ async function signEvent(draft): Promise<NostrEvent> {
|
||||
const signedEventJson = await intentRequest(createSignEventIntent(draft));
|
||||
const signedEvent = JSON.parse(signedEventJson) as NostrEvent;
|
||||
|
||||
if (!verifySignature(signedEvent)) throw new Error("Invalid signature");
|
||||
if (!verifyEvent(signedEvent)) throw new Error("Invalid signature");
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("2 + 2", () => {
|
||||
expect(2 + 2).toBe(4);
|
||||
});
|
470
ui/noogle/src/components/nostr-ignition/NostrIgnition.ts
Normal file
470
ui/noogle/src/components/nostr-ignition/NostrIgnition.ts
Normal file
@ -0,0 +1,470 @@
|
||||
import { NIP05_REGEX, queryProfile } from "nostr-tools/nip05";
|
||||
import { NPUB_REGEX, Nip46, PUBKEY_REGEX } from "./nip46";
|
||||
import type { Nip46Response, BunkerProfile, KeyPair } from "./nip46";
|
||||
import { decode, npubEncode } from "nostr-tools/nip19";
|
||||
import type { UnsignedEvent } from "nostr-tools";
|
||||
import { hexToBytes } from "@noble/hashes/utils";
|
||||
import { checkNip05Availability } from "./utils";
|
||||
|
||||
type NostrIgnitionOptions = {
|
||||
appName: string;
|
||||
redirectUri: string;
|
||||
relays?: string[];
|
||||
};
|
||||
|
||||
let options: NostrIgnitionOptions;
|
||||
let nip46: Nip46;
|
||||
let availableBunkers: BunkerProfile[] = [];
|
||||
let localBunker: BunkerProfile | undefined = undefined;
|
||||
let hasConnected = false;
|
||||
|
||||
const init = async (ignitionOptions: NostrIgnitionOptions) => {
|
||||
// Only do something if the window.nostr object doesn't exist
|
||||
// e.g. we don't have a NIP-07 extension
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(window as any).nostr) {
|
||||
console.log("Initializing Nostr Ignition...");
|
||||
|
||||
const pubkey = localStorage.getItem("localNostrPubkey");
|
||||
const privkey = localStorage.getItem("localNostrPrivkey");
|
||||
if (pubkey && privkey) {
|
||||
console.log("Using local keypair");
|
||||
const keys: KeyPair = { privateKey: hexToBytes(privkey), publicKey: pubkey };
|
||||
nip46 = new Nip46(keys); // instantiate the NIP-46 class with local keys
|
||||
} else {
|
||||
console.log("Generating new keypair");
|
||||
nip46 = new Nip46(); // instantiate the NIP-46 class with no keys
|
||||
}
|
||||
|
||||
options = ignitionOptions; // Set the options
|
||||
|
||||
// Check for available bunkers have to do this before modal is created
|
||||
availableBunkers = await nip46.fetchBunkers();
|
||||
localBunker = availableBunkers.find((bunker) => bunker.local) || undefined;
|
||||
|
||||
// Build the modal
|
||||
const modal = await createModal(); // Create the modal element
|
||||
|
||||
// Get the modal elements
|
||||
const nostrModalClose = document.getElementById("nostr_ignition__nostrModalClose") as HTMLButtonElement;
|
||||
const nostrModalCreateContainer = document.getElementById("nostr_ignition__createAccount") as HTMLDivElement;
|
||||
const nostrModalConnectContainer = document.getElementById("nostr_ignition__connectAccount") as HTMLDivElement;
|
||||
const nostrModalSwitchToSignIn = document.getElementById("nostr_ignition__switchToSignIn") as HTMLButtonElement;
|
||||
const nostrModalSwitchToCreateAccount = document.getElementById(
|
||||
"nostr_ignition__switchToCreateAccount"
|
||||
) as HTMLButtonElement;
|
||||
|
||||
// Create account form
|
||||
const nostrModalNip05 = document.getElementById("nostr_ignition__nostrModalNip05") as HTMLInputElement;
|
||||
const nostrModalBunker = document.getElementById("nostr_ignition__nostrModalBunker") as HTMLSelectElement;
|
||||
const nostrModalEmail = document.getElementById("nostr_ignition__nostrModalEmail") as HTMLInputElement;
|
||||
const nostrModalCreateSubmit = document.getElementById(
|
||||
"nostr_ignition__nostrModalCreateSubmit"
|
||||
) as HTMLButtonElement;
|
||||
const nostrModalCreateSubmitText = document.getElementById(
|
||||
"nostr_ignition__nostrModalCreateSubmitText"
|
||||
) as HTMLSpanElement;
|
||||
const nostrModalCreateSubmitSpinner = document.getElementById(
|
||||
"nostr_ignition__nostrModalCreateSubmitSpinner"
|
||||
) as HTMLSpanElement;
|
||||
const nostrModalNip05Error = document.getElementById("nostr_ignition__nostrModalNip05Error") as HTMLSpanElement;
|
||||
const nostrModalBunkerError = document.getElementById(
|
||||
"nostr_ignition__nostrModalBunkerError"
|
||||
) as HTMLSpanElement;
|
||||
|
||||
// Sign in form
|
||||
const nostrModalNpubOrNip05 = document.getElementById(
|
||||
"nostr_ignition__nostrModalNpubOrNip05"
|
||||
) as HTMLInputElement;
|
||||
const nostrModalNpubOrNip05Error = document.getElementById(
|
||||
"nostr_ignition__nostrModalNpubOrNip05Error"
|
||||
) as HTMLSpanElement;
|
||||
const nostrModalSignInSubmit = document.getElementById(
|
||||
"nostr_ignition__nostrModalSignInSubmit"
|
||||
) as HTMLButtonElement;
|
||||
const nostrModalSignInSubmitText = document.getElementById(
|
||||
"nostr_ignition__nostrModalSignInSubmitText"
|
||||
) as HTMLSpanElement;
|
||||
const nostrModalSignInSubmitSpinner = document.getElementById(
|
||||
"nostr_ignition__nostrModalSignInSubmitSpinner"
|
||||
) as HTMLSpanElement;
|
||||
|
||||
const SIGNIN_TIMEOUT = 10000; // 10 seconds
|
||||
let signInTimeoutFunction: NodeJS.Timeout | null = null;
|
||||
|
||||
// If we had local keys, default to the sign in form
|
||||
if (pubkey && privkey) {
|
||||
nostrModalCreateContainer.style.display = "none";
|
||||
nostrModalConnectContainer.style.display = "block";
|
||||
}
|
||||
|
||||
// Update the app name safely (escaping content provided by user)
|
||||
const appName = document.getElementById("nostr_ignition__appName") as HTMLSpanElement;
|
||||
appName.innerText = options.appName;
|
||||
|
||||
// Add the available bunkers to the select element safely
|
||||
// (escaping content provided by user generated events)
|
||||
availableBunkers.forEach((bunker) => {
|
||||
const option = document.createElement("option");
|
||||
option.setAttribute("value", bunker.domain);
|
||||
option.innerText = bunker.domain;
|
||||
nostrModalBunker.appendChild(option);
|
||||
});
|
||||
|
||||
// Create the window.nostr object and anytime it's called, show the modal
|
||||
Object.defineProperty(window, "nostr", {
|
||||
get: function () {
|
||||
showModal(modal);
|
||||
},
|
||||
});
|
||||
|
||||
// Function to reset forms
|
||||
const resetForms = () => {
|
||||
if (signInTimeoutFunction) clearTimeout(signInTimeoutFunction);
|
||||
nostrModalNip05.value = "";
|
||||
nostrModalNip05.classList.remove("invalid");
|
||||
nostrModalBunkerError.classList.remove("invalid");
|
||||
nostrModalNip05Error.style.display = "none";
|
||||
nostrModalBunkerError.style.display = "none";
|
||||
nostrModalCreateSubmit.disabled = false;
|
||||
nostrModalCreateSubmitText.style.display = "block";
|
||||
nostrModalCreateSubmitSpinner.style.display = "none";
|
||||
nostrModalNpubOrNip05.value = "";
|
||||
nostrModalNpubOrNip05.classList.remove("invalid");
|
||||
nostrModalNpubOrNip05Error.style.display = "none";
|
||||
nostrModalSignInSubmit.disabled = false;
|
||||
nostrModalSignInSubmitText.style.display = "block";
|
||||
nostrModalSignInSubmitSpinner.style.display = "none";
|
||||
nostrModalCreateContainer.style.display = "none";
|
||||
nostrModalConnectContainer.style.display = "block";
|
||||
modal.close();
|
||||
};
|
||||
|
||||
// Add event listener to close the modal
|
||||
nostrModalClose.addEventListener("click", function () {
|
||||
modal.close();
|
||||
});
|
||||
|
||||
// Add event listeners to switch between sign in and create account
|
||||
nostrModalSwitchToSignIn.addEventListener("click", function () {
|
||||
nostrModalCreateContainer.style.display = "none";
|
||||
nostrModalConnectContainer.style.display = "block";
|
||||
});
|
||||
|
||||
nostrModalSwitchToCreateAccount.addEventListener("click", function () {
|
||||
nostrModalCreateContainer.style.display = "block";
|
||||
nostrModalConnectContainer.style.display = "none";
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Create account form
|
||||
*
|
||||
*/
|
||||
|
||||
// Add event listener to the username input to check availability
|
||||
nostrModalNip05.addEventListener("input", function () {
|
||||
checkNip05Availability(`${nostrModalNip05.value}@${nostrModalBunker.value}`, localBunker).then(
|
||||
(available) => {
|
||||
if (available) {
|
||||
nostrModalNip05.setCustomValidity("");
|
||||
nostrModalCreateSubmit.disabled = false;
|
||||
nostrModalNip05.classList.remove("invalid");
|
||||
nostrModalNip05Error.style.display = "none";
|
||||
nostrModalBunkerError.style.display = "none";
|
||||
} else {
|
||||
nostrModalCreateSubmit.disabled = true;
|
||||
nostrModalNip05.setCustomValidity("Username is not available");
|
||||
nostrModalNip05.classList.add("invalid");
|
||||
nostrModalNip05Error.style.display = "block";
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Add an event listener to the form to create the account
|
||||
nostrModalCreateSubmit.addEventListener("click", async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
nostrModalCreateSubmit.disabled = true;
|
||||
nostrModalCreateSubmitText.style.display = "none";
|
||||
nostrModalCreateSubmitSpinner.style.display = "block";
|
||||
|
||||
const bunkerPubkey = availableBunkers.find((bunker) => bunker.domain === nostrModalBunker.value)?.pubkey;
|
||||
|
||||
// Add error if we don't have valid details
|
||||
if (!nostrModalBunker.value || !bunkerPubkey) {
|
||||
nostrModalCreateSubmit.disabled = true;
|
||||
nostrModalBunker.setCustomValidity("Error creating account. Please try again later.");
|
||||
nostrModalBunker.classList.add("invalid");
|
||||
nostrModalBunkerError.style.display = "block";
|
||||
// Remove spinner and re-enable submit button
|
||||
nostrModalCreateSubmit.disabled = false;
|
||||
nostrModalCreateSubmitText.style.display = "block";
|
||||
nostrModalCreateSubmitSpinner.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger the create account flow
|
||||
createAccount(
|
||||
bunkerPubkey,
|
||||
nostrModalNip05.value,
|
||||
nostrModalBunker.value,
|
||||
nostrModalEmail.value || undefined
|
||||
)
|
||||
.then((response) => {
|
||||
if (response && response.error) {
|
||||
openNewWindow(`${response.error}?redirect_uri=${options.redirectUri}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* Sign in form
|
||||
*
|
||||
*/
|
||||
|
||||
// Add event listener to enable submit button
|
||||
nostrModalNpubOrNip05.addEventListener("input", function () {
|
||||
nostrModalNpubOrNip05Error.innerText = "";
|
||||
nostrModalNpubOrNip05Error.style.display = "none";
|
||||
nostrModalNpubOrNip05.classList.remove("invalid");
|
||||
if (nostrModalNpubOrNip05.value.length > 0) {
|
||||
nostrModalSignInSubmit.disabled = false;
|
||||
} else {
|
||||
nostrModalSignInSubmit.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listener for sign in form
|
||||
nostrModalSignInSubmit.addEventListener("click", async function (event) {
|
||||
event.preventDefault();
|
||||
nostrModalSignInSubmit.disabled = true;
|
||||
nostrModalSignInSubmitText.style.display = "none";
|
||||
nostrModalSignInSubmitSpinner.style.display = "block";
|
||||
|
||||
let remotePubkey: string | null = null;
|
||||
// Order is important here. NIP05_REGEX is pretty loose so it will match an npub
|
||||
if (NPUB_REGEX.test(nostrModalNpubOrNip05.value)) {
|
||||
// Decode pubkey from npub
|
||||
remotePubkey = decode(nostrModalNpubOrNip05.value).data as string;
|
||||
} else if (PUBKEY_REGEX.test(nostrModalNpubOrNip05.value)) {
|
||||
// Looks like a pubkey
|
||||
remotePubkey = nostrModalNpubOrNip05.value;
|
||||
} else if (NIP05_REGEX.test(nostrModalNpubOrNip05.value)) {
|
||||
// Look up pubkey for nip05
|
||||
const profilePointer = await queryProfile(nostrModalNpubOrNip05.value);
|
||||
if (profilePointer) {
|
||||
remotePubkey = profilePointer.pubkey;
|
||||
} else {
|
||||
nostrModalNpubOrNip05.setCustomValidity("Error fetching Pubkey from NIP-05 value.");
|
||||
nostrModalNpubOrNip05.classList.add("invalid");
|
||||
nostrModalNpubOrNip05Error.innerText = "Error fetching Pubkey from NIP-05 value.";
|
||||
nostrModalNpubOrNip05Error.style.display = "block";
|
||||
// Remove spinner and re-enable submit button
|
||||
nostrModalSignInSubmit.disabled = false;
|
||||
nostrModalSignInSubmitText.style.display = "block";
|
||||
nostrModalSignInSubmitSpinner.style.display = "none";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Nothing matches the value - it's an error.
|
||||
nostrModalNpubOrNip05.setCustomValidity("Invalid Pubkey, npub, or NIP-05");
|
||||
nostrModalNpubOrNip05.classList.add("invalid");
|
||||
// Remove spinner and re-enable submit button
|
||||
nostrModalSignInSubmit.disabled = false;
|
||||
nostrModalSignInSubmitText.style.display = "block";
|
||||
nostrModalSignInSubmitSpinner.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
connect(remotePubkey)
|
||||
.then((response) => {
|
||||
if (response && response.result === "ack") {
|
||||
hasConnected = true;
|
||||
resetForms();
|
||||
}
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
|
||||
signInTimeoutFunction = setTimeout(() => {
|
||||
nostrModalNpubOrNip05Error.innerText =
|
||||
"No response from a remote signer. Are you sure there is an available remote signer managing this Pubkey?";
|
||||
nostrModalNpubOrNip05Error.style.display = "block";
|
||||
// Remove spinner and re-enable submit button
|
||||
nostrModalSignInSubmit.disabled = false;
|
||||
nostrModalSignInSubmitText.style.display = "block";
|
||||
nostrModalSignInSubmitSpinner.style.display = "none";
|
||||
}, SIGNIN_TIMEOUT);
|
||||
});
|
||||
|
||||
nip46.on("authChallengeSuccess", async (response: Nip46Response) => {
|
||||
if (response.result === "ack") {
|
||||
console.log("Connected to bunker");
|
||||
hasConnected = true;
|
||||
resetForms();
|
||||
} else if (response.result === "pong") {
|
||||
console.log("Pong!");
|
||||
} else if (PUBKEY_REGEX.test(response.result)) {
|
||||
console.log("Account created with pubkey: ", response.result);
|
||||
nip46.remotePubkey = response.result;
|
||||
hasConnected = true;
|
||||
resetForms();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Function to create and show the modal using <dialog>
|
||||
const createModal = async (): Promise<HTMLDialogElement> => {
|
||||
// Create the dialog element
|
||||
const dialog: HTMLDialogElement = document.createElement("dialog");
|
||||
dialog.id = "nostr_ignition__nostrModal";
|
||||
|
||||
// Add content to the dialog
|
||||
const dialogContent: HTMLDivElement = document.createElement("div");
|
||||
dialogContent.innerHTML = `
|
||||
<button id="nostr_ignition__nostrModalClose"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-square"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg></button>
|
||||
<h2 id="nostr_ignition__nostrModalTitle"><span id="nostr_ignition__appName">This app</span> uses Nostr for accounts</h2>
|
||||
<div id="nostr_ignition__createAccount">
|
||||
<p>Would you like to create a new Nostr account? Identities on Nostr are portable so you'll be able to use this account on any other Nostr client.</p>
|
||||
<form id="nostr_ignition__nostrCreateAccountForm" class="nostr_ignition__nostrModalForm">
|
||||
<span class="nostr_ignition__inputWrapper">
|
||||
<input type="text" id="nostr_ignition__nostrModalNip05" name="nostrModalNip05" placeholder="Username" required />
|
||||
<select id="nostr_ignition__nostrModalBunker" name="nostrModalBunker" required>
|
||||
</select>
|
||||
</span>
|
||||
<span id="nostr_ignition__nostrModalNip05Error" class="nostr_ignition__nostrModalError">Username not available</span>
|
||||
<span id="nostr_ignition__nostrModalBunkerError" class="nostr_ignition__nostrModalError">Error creating account</span>
|
||||
<span class="nostr_ignition__inputWrapperFull">
|
||||
<input type="email" id="nostr_ignition__nostrModalEmail" name="nostrModalEmail" placeholder="Email address. Optional, for account recovery." />
|
||||
</span>
|
||||
<button type="submit" id="nostr_ignition__nostrModalCreateSubmit" disabled>
|
||||
<span id="nostr_ignition__nostrModalCreateSubmitText">Create account</span>
|
||||
<span id="nostr_ignition__nostrModalCreateSubmitSpinner"></span>
|
||||
</button>
|
||||
</form>
|
||||
<button id="nostr_ignition__switchToSignIn" class="nostr_ignition__linkButton">Already have a Nostr account? Sign in instead.</button>
|
||||
</div>
|
||||
<div id="nostr_ignition__connectAccount" style="display:none;">
|
||||
<p style="text-align: center;">Sign in with your Pubkey, npub, or NIP-05.</p>
|
||||
<form id="nostr_ignition__nostrSignInForm" class="nostr_ignition__nostrModalForm">
|
||||
<span class="nostr_ignition__inputWrapper">
|
||||
<input type="text" id="nostr_ignition__nostrModalNpubOrNip05" name="nostrModalNpubOrNip05" placeholder="Pubkey, npub, or NIP-05" required />
|
||||
</span>
|
||||
<span id="nostr_ignition__nostrModalNpubOrNip05Error" class="nostr_ignition__nostrModalError"></span>
|
||||
<button type="submit" id="nostr_ignition__nostrModalSignInSubmit" disabled>
|
||||
<span id="nostr_ignition__nostrModalSignInSubmitText">Sign in</span>
|
||||
<span id="nostr_ignition__nostrModalSignInSubmitSpinner"></span>
|
||||
</button>
|
||||
</form>
|
||||
<button id="nostr_ignition__switchToCreateAccount" class="nostr_ignition__linkButton">No Nostr account? Create a new account.</button>
|
||||
</div>
|
||||
<div id="nostr_ignition__nostrModalLearnMore">Not sure what Nostr is? Check out <a href="https://nostr.how" target="_blank">Nostr.how</a> for more info</div>
|
||||
`;
|
||||
dialog.appendChild(dialogContent);
|
||||
|
||||
// Append the dialog to the document body
|
||||
document.body.appendChild(dialog);
|
||||
return dialog;
|
||||
};
|
||||
|
||||
// Function to show the modal
|
||||
const showModal = (dialog: HTMLDialogElement): void => {
|
||||
dialog.showModal();
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens a new window with the specified URL.
|
||||
* @param url - The URL to open in the new window.
|
||||
*/
|
||||
const openNewWindow = (url: string): void => {
|
||||
const width = 600; // Desired width of the window
|
||||
const height = 800; // Desired height of the window
|
||||
|
||||
const windowFeatures = `width=${width},height=${height},popup=yes`;
|
||||
window.open(url, "nostrIgnition", windowFeatures);
|
||||
};
|
||||
|
||||
const remoteNpub = (): string | null => {
|
||||
return nip46.remotePubkey ? npubEncode(nip46.remotePubkey) : null;
|
||||
};
|
||||
|
||||
const remotePubkey = (): string | null => {
|
||||
return nip46.remotePubkey;
|
||||
};
|
||||
|
||||
const connected = (): boolean => {
|
||||
return hasConnected;
|
||||
};
|
||||
|
||||
const connect = async (remotePubkey: string): Promise<Nip46Response | void> => {
|
||||
console.log("Connecting to bunker...");
|
||||
return nip46
|
||||
.connect(remotePubkey)
|
||||
.then((response) => {
|
||||
if (response.result === "auth_url" && response.error) {
|
||||
openNewWindow(`${response.error}?redirect_uri=${options.redirectUri}`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
};
|
||||
|
||||
const createAccount = async (
|
||||
bunkerPubkey: string,
|
||||
username: string,
|
||||
domain: string,
|
||||
email?: string
|
||||
): Promise<Nip46Response | void> => {
|
||||
console.log("Creating account...");
|
||||
return nip46
|
||||
.createAccount(bunkerPubkey, username, domain, email)
|
||||
.then((response) => response)
|
||||
.catch((error) => console.error(error));
|
||||
};
|
||||
|
||||
const ping = async (): Promise<Nip46Response | void> => {
|
||||
console.log("Pinging bunker...");
|
||||
nip46
|
||||
.ping()
|
||||
.then((response) => {
|
||||
if (response.result === "pong") {
|
||||
console.log("Pong!");
|
||||
} else if (response.result === "auth_url" && response.error) {
|
||||
openNewWindow(`${response.error}?redirect_uri=${options.redirectUri}`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
};
|
||||
|
||||
const signEvent = async (event: UnsignedEvent): Promise<Nip46Response | void> => {
|
||||
console.log("Requesting signature...");
|
||||
return nip46
|
||||
.sign_event(event)
|
||||
.then((response) => {
|
||||
if (response.result === "auth_url" && response.error) {
|
||||
openNewWindow(`${response.error}?redirect_uri=${options.redirectUri}`);
|
||||
} else {
|
||||
console.log("Signed event:", JSON.parse(response.result));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
init,
|
||||
createAccount,
|
||||
ping,
|
||||
connect,
|
||||
signEvent,
|
||||
remoteNpub,
|
||||
remotePubkey,
|
||||
connected,
|
||||
};
|
189
ui/noogle/src/components/nostr-ignition/index.css
Normal file
189
ui/noogle/src/components/nostr-ignition/index.css
Normal file
@ -0,0 +1,189 @@
|
||||
@media screen and (max-width: 767px) {
|
||||
#nostr_ignition__nostrModal {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 768px) {
|
||||
#nostr_ignition__nostrModal {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1024px) {
|
||||
#nostr_ignition__nostrModal {
|
||||
width: 40%;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1440px) {
|
||||
#nostr_ignition__nostrModal {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
#nostr_ignition__nostrModal {
|
||||
margin: 6rem auto;
|
||||
border: 1px rgba(0, 0, 0, 0.6) solid;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#nostr_ignition__nostrModal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#nostr_ignition__nostrModal > div {
|
||||
padding: 0.875rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#nostr_ignition__nostrModalClose {
|
||||
position: absolute;
|
||||
top: 0rem;
|
||||
right: 0rem;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#nostr_ignition__nostrModalClose svg {
|
||||
stroke: #444;
|
||||
}
|
||||
#nostr_ignition__nostrModalClose:hover svg {
|
||||
color: #000;
|
||||
fill: #eee;
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
#nostr_ignition__nostrModalTitle {
|
||||
margin: 1rem 0 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nostr_ignition__inputWrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nostr_ignition__inputWrapperFull {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalForm input[type="text"],
|
||||
.nostr_ignition__nostrModalForm input[type="email"] {
|
||||
padding: 0.5rem;
|
||||
border: 1px rgba(0, 0, 0, 0.6) solid;
|
||||
border-radius: 5px;
|
||||
flex-grow: 1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalForm input[type="text"].invalid,
|
||||
.nostr_ignition__nostrModalForm input[type="email"].invalid,
|
||||
.nostr_ignition__nostrModalForm select.invalid {
|
||||
border: 1px red solid;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalForm select {
|
||||
padding: 0.5rem;
|
||||
border: 1px rgba(0, 0, 0, 0.6) solid;
|
||||
border-radius: 5px;
|
||||
flex-grow: 1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalForm label {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalError {
|
||||
color: red;
|
||||
display: none;
|
||||
align-self: flex-start;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalForm button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
items-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalForm button[type="submit"]:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.nostr_ignition__nostrModalForm button[type="submit"]:disabled {
|
||||
background: #555;
|
||||
cursor: not-allowed;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.nostr_ignition__linkButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #000;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
margin: 1rem auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#nostr_ignition__nostrModalLearnMore {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
#nostr_ignition__nostrModalCreateSubmitSpinner,
|
||||
#nostr_ignition__nostrModalSignInSubmitSpinner {
|
||||
display: none;
|
||||
}
|
||||
#nostr_ignition__nostrModalCreateSubmitSpinner:after,
|
||||
#nostr_ignition__nostrModalSignInSubmitSpinner:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
border-radius: 50%;
|
||||
border: 0.2rem solid #fff;
|
||||
border-color: #fff transparent #fff transparent;
|
||||
animation: lds-dual-ring 1.2s linear infinite;
|
||||
}
|
||||
@keyframes lds-dual-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
3
ui/noogle/src/components/nostr-ignition/index.ts
Normal file
3
ui/noogle/src/components/nostr-ignition/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import NostrIgnition from "./NostrIgnition";
|
||||
|
||||
Object.assign(window, { NostrIgnition });
|
5
ui/noogle/src/components/nostr-ignition/nip46.test.ts
Normal file
5
ui/noogle/src/components/nostr-ignition/nip46.test.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("2 + 2", () => {
|
||||
expect(2 + 2).toBe(4);
|
||||
});
|
308
ui/noogle/src/components/nostr-ignition/nip46.ts
Normal file
308
ui/noogle/src/components/nostr-ignition/nip46.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import { SimplePool } from "nostr-tools/pool";
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent, type Event, type UnsignedEvent } from "nostr-tools/pure";
|
||||
import type { SubCloser, SubscribeManyParams, VerifiedEvent } from "nostr-tools";
|
||||
import { encrypt, decrypt } from "nostr-tools/nip04";
|
||||
import { NostrConnect, Handlerinformation } from "nostr-tools/kinds";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { EventEmitter } from "events";
|
||||
import { validateBunkerNip05, generateReqId } from "./utils";
|
||||
|
||||
const DEFAULT_RELAYS = ["wss://relay.nostr.band", "wss://relay.nsecbunker.com"];
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
export const NPUB_REGEX = /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/;
|
||||
export const PUBKEY_REGEX = /^[0-9a-z]{64}$/;
|
||||
|
||||
export type KeyPair = { privateKey: Uint8Array; publicKey: string };
|
||||
|
||||
export type Nip46Response = {
|
||||
id: string;
|
||||
result: string;
|
||||
error?: string;
|
||||
event: Event;
|
||||
};
|
||||
|
||||
export type BunkerProfile = {
|
||||
pubkey: string;
|
||||
domain: string;
|
||||
nip05: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
about: string;
|
||||
website: string;
|
||||
local: boolean;
|
||||
};
|
||||
|
||||
// If you're running a local nsecbunker for testing you can add it here
|
||||
// to have it show up in the list of available bunkers.
|
||||
// The pubkey is the pubkey of the nsecbunker, not the localNostrPubkey
|
||||
// The domain must be the domain configured in the nsecbunker.json file
|
||||
// All other fields are optional
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let localBunker: BunkerProfile | undefined = undefined;
|
||||
|
||||
// Uncomment this block to add a local nsecbunker for testing
|
||||
// localBunker = {
|
||||
// pubkey: "2ba00ed9b2108bf16de47fb3e2656bed051e314b1afa4dc04c213e67f41f28e1",
|
||||
// nip05: "",
|
||||
// domain: "really-trusted-oyster.ngrok-free.app",
|
||||
// name: "",
|
||||
// picture: "",
|
||||
// about: "",
|
||||
// website: "",
|
||||
// local: true,
|
||||
// };
|
||||
|
||||
export class Nip46 extends EventEmitter {
|
||||
private pool: SimplePool;
|
||||
private subscription: SubCloser | undefined;
|
||||
private relays: string[];
|
||||
public keys: KeyPair | undefined;
|
||||
public remotePubkey: string | null;
|
||||
|
||||
/**
|
||||
* Creates a new instance of the Nip46 class.
|
||||
* @param relays - An optional array of relay addresses.
|
||||
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
|
||||
* @param keys - An optional key pair.
|
||||
*/
|
||||
public constructor(keys?: KeyPair, remotePubkey?: string, relays?: string[]) {
|
||||
super();
|
||||
|
||||
this.pool = new SimplePool();
|
||||
this.relays = relays || DEFAULT_RELAYS;
|
||||
this.remotePubkey = remotePubkey || null;
|
||||
this.keys = keys || this.generateAndStoreKey();
|
||||
if (!this.subscription) this.subscribeToNostrConnectEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a key pair, stores the keys in localStorage.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
private generateAndStoreKey(): KeyPair {
|
||||
const privateKey = generateSecretKey();
|
||||
const publicKey = getPublicKey(privateKey);
|
||||
// localNostrPubkey is the key that we use to publish events asking nsecbunkers for real signatures
|
||||
localStorage.setItem("localNostrPubkey", publicKey);
|
||||
localStorage.setItem("localNostrPrivkey", bytesToHex(privateKey));
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to Nostr Connect events (kind 24133 and 24134) for the provided keys and relays.
|
||||
* It sets up a subscription to receive events and emit events for the received responses.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
private subscribeToNostrConnectEvents(): void {
|
||||
// Bail early if we don't have a local keypair
|
||||
if (!this.keys) return;
|
||||
|
||||
// We do this alias because inside the onevent function, `this` is the event object
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
// const nip46 = this;
|
||||
const parseResponseEvent = this.parseResponseEvent.bind(this);
|
||||
|
||||
const subManyParams: SubscribeManyParams = {
|
||||
async onevent(event) {
|
||||
parseResponseEvent(event);
|
||||
},
|
||||
oneose() {
|
||||
console.log("EOSE received");
|
||||
},
|
||||
};
|
||||
|
||||
this.subscription = this.pool.subscribeMany(
|
||||
this.relays,
|
||||
[{ kinds: [NostrConnect, 24134], "#p": [this.keys.publicKey] }],
|
||||
subManyParams
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches info on available signers (nsecbunkers) using NIP-89 events.
|
||||
*
|
||||
* @returns A promise that resolves to an array of available bunker objects.
|
||||
*/
|
||||
async fetchBunkers(): Promise<BunkerProfile[]> {
|
||||
const events = await this.pool.querySync(this.relays, { kinds: [Handlerinformation] });
|
||||
// Filter for events that handle the connect event kind
|
||||
const filteredEvents = events.filter((event) =>
|
||||
event.tags.some((tag) => tag[0] === "k" && tag[1] === NostrConnect.toString())
|
||||
);
|
||||
|
||||
// Validate bunkers by checking their NIP-05 and pubkey
|
||||
// Map to a more useful object
|
||||
const validatedBunkers = await Promise.all(
|
||||
filteredEvents.map(async (event) => {
|
||||
const content = JSON.parse(event.content);
|
||||
const valid = await validateBunkerNip05(content.nip05, event.pubkey);
|
||||
if (valid) {
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
nip05: content.nip05,
|
||||
domain: content.nip05.split("@")[1],
|
||||
name: content.name || content.display_name,
|
||||
picture: content.picture,
|
||||
about: content.about,
|
||||
website: content.website,
|
||||
local: false,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Add local bunker if it exists
|
||||
if (localBunker) validatedBunkers.unshift(localBunker);
|
||||
|
||||
return validatedBunkers.filter((bunker) => bunker !== undefined) as BunkerProfile[];
|
||||
}
|
||||
|
||||
async sendRequest(id: string, method: string, params: string[], remotePubkey?: string): Promise<Nip46Response> {
|
||||
if (!this.keys) throw new Error("No keys found");
|
||||
const remotePk: string = (remotePubkey || this.remotePubkey) as string;
|
||||
if (!remotePk) throw new Error("No remote public key found");
|
||||
|
||||
// Encrypt the content for the bunker (NIP-04)
|
||||
const encryptedContent = await encrypt(this.keys.privateKey, remotePk, JSON.stringify({ id, method, params }));
|
||||
|
||||
// Create event to sign
|
||||
const verifiedEvent: VerifiedEvent = finalizeEvent(
|
||||
{
|
||||
kind: method === "create_account" ? 24134 : NostrConnect,
|
||||
tags: [["p", remotePk]],
|
||||
content: encryptedContent,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
this.keys.privateKey
|
||||
);
|
||||
|
||||
// Build auth_url handler
|
||||
const authHandler = (response: Nip46Response) => {
|
||||
if (response.result) {
|
||||
this.emit("authChallengeSuccess", response);
|
||||
} else {
|
||||
this.emit("authChallengeError", response.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Build the response handler
|
||||
const responsePromise = new Promise<Nip46Response>((resolve, reject) => {
|
||||
this.once(`response-${id}`, (response: Nip46Response) => {
|
||||
// Create account or auth challenge
|
||||
if (response.result === "auth_url") {
|
||||
this.once(`response-${id}`, authHandler);
|
||||
resolve(response);
|
||||
} else if (response.error) {
|
||||
reject(response.error);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Publish the event
|
||||
await Promise.any(this.pool.publish(this.relays, verifiedEvent));
|
||||
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a response event and decrypts its content using the recipient's private key.
|
||||
*
|
||||
* @param event - The response event to parse.
|
||||
* @throws {Error} If no keys are found.
|
||||
* @returns An object containing the parsed response event data.
|
||||
*/
|
||||
async parseResponseEvent(responseEvent: Event): Promise<Nip46Response> {
|
||||
if (!this.keys) throw new Error("No keys found");
|
||||
const decryptedContent = await decrypt(this.keys.privateKey, responseEvent.pubkey, responseEvent.content);
|
||||
const parsedContent = JSON.parse(decryptedContent);
|
||||
const { id, result, error, event } = parsedContent;
|
||||
this.emit(`response-${id}`, parsedContent as Nip46Response);
|
||||
return { id, result, error, event };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a ping request to the remote server.
|
||||
* Requires permission/access rights to bunker.
|
||||
* @throws {Error} If no keys are found or no remote public key is found.
|
||||
* @returns "Pong" if successful. The promise will reject if the response is not "pong".
|
||||
*/
|
||||
async ping(): Promise<Nip46Response> {
|
||||
if (!this.keys) throw new Error("No keys found");
|
||||
if (!this.remotePubkey) throw new Error("No remote public key found");
|
||||
|
||||
const reqId = generateReqId();
|
||||
const params: string[] = [];
|
||||
|
||||
return this.sendRequest(reqId, "ping", params, this.remotePubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to a remote server using the provided keys and remote public key.
|
||||
* Optionally, a secret can be provided for additional authentication.
|
||||
*
|
||||
* @param remotePubkey - Optional the remote public key to connect to.
|
||||
* @param secret - Optional secret for additional authentication.
|
||||
* @throws {Error} If no keys are found or no remote public key is found.
|
||||
* @returns "ack" if successful. The promise will reject if the response is not "ack".
|
||||
*/
|
||||
async connect(remotePubkey?: string, secret?: string): Promise<Nip46Response> {
|
||||
if (!this.keys) throw new Error("No keys found");
|
||||
if (remotePubkey) this.remotePubkey = remotePubkey;
|
||||
if (!this.remotePubkey) throw new Error("No remote public key found");
|
||||
|
||||
const reqId = generateReqId();
|
||||
const params: string[] = [this.keys.publicKey];
|
||||
if (secret) params.push(secret);
|
||||
|
||||
return this.sendRequest(reqId, "connect", params, remotePubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an event using the remote private key.
|
||||
* @param event - The event to sign.
|
||||
* @throws {Error} If no keys are found or no remote public key is found.
|
||||
* @returns A Promise that resolves to the signed event.
|
||||
*/
|
||||
async sign_event(event: UnsignedEvent): Promise<Nip46Response> {
|
||||
if (!this.keys) throw new Error("No keys found");
|
||||
if (!this.remotePubkey) throw new Error("No remote public key found");
|
||||
|
||||
const reqId = generateReqId();
|
||||
// Only param is the event to sign
|
||||
const params: string[] = [JSON.stringify(event)];
|
||||
|
||||
return this.sendRequest(reqId, "sign_event", params, this.remotePubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an account with the specified username, domain, and optional email.
|
||||
* @param bunkerPubkey - The public key of the bunker to use for the create_account call.
|
||||
* @param username - The username for the account.
|
||||
* @param domain - The domain for the account.
|
||||
* @param email - The optional email for the account.
|
||||
* @throws Error if no keys are found, no remote public key is found, or the email is present but invalid.
|
||||
* @returns A Promise that resolves to the auth_url that the client should follow to create an account.
|
||||
*/
|
||||
async createAccount(
|
||||
bunkerPubkey: string,
|
||||
username: string,
|
||||
domain: string,
|
||||
email?: string
|
||||
): Promise<Nip46Response> {
|
||||
if (!this.keys) throw new Error("No keys found");
|
||||
if (email && !EMAIL_REGEX.test(email)) throw new Error("Invalid email");
|
||||
|
||||
const reqId = generateReqId();
|
||||
const params = [username, domain];
|
||||
if (email) params.push(email);
|
||||
|
||||
return this.sendRequest(reqId, "create_account", params, bunkerPubkey);
|
||||
}
|
||||
}
|
5
ui/noogle/src/components/nostr-ignition/utils.test.ts
Normal file
5
ui/noogle/src/components/nostr-ignition/utils.test.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("2 + 2", () => {
|
||||
expect(2 + 2).toBe(4);
|
||||
});
|
56
ui/noogle/src/components/nostr-ignition/utils.ts
Normal file
56
ui/noogle/src/components/nostr-ignition/utils.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { BunkerProfile } from "./nip46";
|
||||
|
||||
/**
|
||||
* Checks the availability of a NIP05 address on a given domain.
|
||||
*
|
||||
* @param nip05 - The NIP05 address to check.
|
||||
* @throws {Error} If the NIP05 address is invalid. e.g. not in the form `name@domain`.
|
||||
* @returns A promise that resolves to a boolean indicating the availability of the NIP05 address.
|
||||
*/
|
||||
export async function checkNip05Availability(nip05: string, localBunker?: BunkerProfile): Promise<boolean> {
|
||||
if (nip05.split("@").length !== 2) throw new Error("Invalid nip05");
|
||||
// Skip availability check if the nip05 is for the local bunker
|
||||
if (localBunker && nip05.split("@")[1] === localBunker.domain) return true;
|
||||
|
||||
const [username, domain] = nip05.split("@");
|
||||
try {
|
||||
const response = await fetch(`https://${domain}/.well-known/nostr.json?name=${username}`);
|
||||
const json = await response.json();
|
||||
return json.names[username] === undefined ? true : false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Bunker's NIP-05.
|
||||
*
|
||||
* @param nip05 - The NIP05 to validate.
|
||||
* @param pubkey - The public key to compare against.
|
||||
* @returns A promise that resolves to a boolean indicating whether the NIP05 is valid for the bunkers pubkey.
|
||||
* Will also return false for invalid nip05 format.
|
||||
*/
|
||||
export async function validateBunkerNip05(nip05: string, pubkey: string): Promise<boolean> {
|
||||
if (nip05.split("@").length !== 2) return false;
|
||||
|
||||
const domain = nip05.split("@")[1];
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://${domain}/.well-known/nostr.json?name=_`);
|
||||
const json = await response.json();
|
||||
return json.names["_"] === pubkey;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique request ID.
|
||||
*
|
||||
* @returns {string} The generated request ID.
|
||||
*/
|
||||
export function generateReqId(): string {
|
||||
return Math.random().toString(36).substring(7);
|
||||
}
|
@ -4,7 +4,6 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import store from './store';
|
||||
import "./app.css"
|
||||
|
||||
import 'vue3-easy-data-table/dist/style.css';
|
||||
import router from './router'
|
||||
import Vue3EasyDataTable from 'vue3-easy-data-table';
|
||||
@ -30,6 +29,8 @@ function toast ({title, message, type, timeout, cb}) {
|
||||
return miniToastr[type](message, title, timeout, cb)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const options = {
|
||||
success: toast,
|
||||
error: toast,
|
||||
@ -42,7 +43,6 @@ createApp(App)
|
||||
.use(VueNotifications, options)
|
||||
.use(store)
|
||||
.use(router)
|
||||
|
||||
.component('EasyDataTable', Vue3EasyDataTable)
|
||||
.component('VueDatePicker', VueDatePicker)
|
||||
.mount('#app')
|
||||
|
@ -11,6 +11,10 @@
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*",]
|
||||
},
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
Loading…
x
Reference in New Issue
Block a user