| 1 | // PrivateServices edits the encrypted kind 10318 relay list. NIP-44 encryption |
| 2 | // and event signing stay with the user's signer. |
| 3 | (() => { |
| 4 | "use strict" ; |
| 5 | |
| 6 | const {relayURL} = window.tiny.util; |
| 7 | const {signer, nip44Encrypt: encrypt, nip44Decrypt: decrypt} = window.tiny; |
| 8 | const validURL = value => relayURL(value, {strict: true}) !== null; |
| 9 | const relay = value => relayURL(value, {strict: true}); |
| 10 | const mergeRows = (rows, urls) => |
| 11 | rows.filter(row => !(Array.isArray(row) && row[0] === "g" )).concat(urls.map(value => ["g" , value])); |
| 12 | const publicKey = async element => { |
| 13 | const known = element?.getAttribute("pubkey" ); |
| 14 | if (/^[0-9a-f]{64}$/.test(known || "" )) return known; |
| 15 | const active = signer(); |
| 16 | if (active?.getPublicKey) return active.getPublicKey(); |
| 17 | if (window.tiny?.getPublicKey) return window.tiny.getPublicKey(); |
| 18 | throw Error("Connect a signer first." ); |
| 19 | }; |
| 20 | // readList uses the browser session when the page has one, so showing |
| 21 | // the list never asks the signer; a signed query is the fallback. |
| 22 | const readList = async pubkey => { |
| 23 | const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]); |
| 24 | let response = null; |
| 25 | if (typeof fetch === "function" && window.tiny?.localPath) { |
| 26 | response = await fetch(window.tiny.localPath("/query" ), { |
| 27 | method: "POST" , |
| 28 | credentials: "same-origin" , |
| 29 | headers: {"content-type" : "application/json" }, |
| 30 | body |
| 31 | }); |
| 32 | if (response.status === 401) response = null; |
| 33 | } |
| 34 | if (!response) { |
| 35 | response = await window.tiny.signedFetch("/query" , "POST" , body, { |
| 36 | contentType: "application/json" |
| 37 | }); |
| 38 | } |
| 39 | if (!response || response.ok === false) throw Error("Could not read the private relay list." ); |
| 40 | const rows = await response.json(); |
| 41 | if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed." ); |
| 42 | const event = rows[0]; |
| 43 | if (rows.length && !acceptEvent(event, pubkey)) throw Error("The private relay list event is invalid." ); |
| 44 | return event; |
| 45 | }; |
| 46 | const acceptEvent = (event, expectedPubkey) => { |
| 47 | if ( |
| 48 | !event || |
| 49 | event.kind !== 10318 || |
| 50 | event.pubkey !== expectedPubkey || |
| 51 | !Array.isArray(event.tags) || |
| 52 | typeof event.content !== "string" |
| 53 | ) |
| 54 | return false; |
| 55 | if (typeof window.NostrSigner?.verifyEvent !== "function" ) return false; |
| 56 | try { |
| 57 | return window.NostrSigner.verifyEvent(event) === true; |
| 58 | } catch { |
| 59 | return false; |
| 60 | } |
| 61 | }; |
| 62 | const publish = async event => { |
| 63 | const response = await window.tiny.signedFetch("/events" , "POST" , JSON.stringify(event), { |
| 64 | contentType: "application/json" |
| 65 | }); |
| 66 | const text = await response.text(); |
| 67 | let value; |
| 68 | try { |
| 69 | value = JSON.parse(text); |
| 70 | } catch { |
| 71 | value = undefined; |
| 72 | } |
| 73 | if (value?.error || value?.accepted === false) |
| 74 | throw Error(value.error || value.message || "The relay rejected the event." ); |
| 75 | }; |
| 76 | const wireEvent = content => ({ |
| 77 | kind: 10318, |
| 78 | created_at: Math.floor(Date.now() / 1000), |
| 79 | tags: [], |
| 80 | content |
| 81 | }); |
| 82 | |
| 83 | class PrivateServices extends HTMLElement { |
| 84 | connectedCallback() { |
| 85 | if (this.bound) return; |
| 86 | this.bound = true; |
| 87 | this.form = this.querySelector("form" ); |
| 88 | this.lines = this.querySelector("textarea[name=lines]" ); |
| 89 | this.output = this.querySelector("output" ); |
| 90 | this.form?.addEventListener("submit" , event => { |
| 91 | event.preventDefault(); |
| 92 | if (this.busy) return; |
| 93 | this.busy = true; |
| 94 | this.form.querySelector("button" ).disabled = true; |
| 95 | this.save() |
| 96 | .catch(error => this.say("Error: " + error.message, true)) |
| 97 | .finally(() => { |
| 98 | this.busy = false; |
| 99 | this.form.querySelector("button" ).disabled = false; |
| 100 | }); |
| 101 | }); |
| 102 | this.querySelector("[data-decrypt]" )?.addEventListener("click" , () => { |
| 103 | this.whenSigned(() => this.reveal().catch(error => this.say("Error: " + error.message, true))); |
| 104 | }); |
| 105 | this.load() |
| 106 | .then(() => { |
| 107 | this.form.querySelector("button" ).disabled = false; |
| 108 | }) |
| 109 | .catch(error => this.say("Error: " + error.message, true)); |
| 110 | } |
| 111 | |
| 112 | // whenSigned runs once a signer is available. The bridge announces a |
| 113 | // connected or resumed remote signer with the tiny:signer event. |
| 114 | whenSigned(fn) { |
| 115 | if (signer()?.signEvent || window.nostr?.signEvent) return fn(); |
| 116 | this.say("Connect a signer to edit this list." ); |
| 117 | document.addEventListener("tiny:signer" , () => fn(), {once: true}); |
| 118 | } |
| 119 | |
| 120 | say(text, error) { |
| 121 | this.output.textContent = text; |
| 122 | if (error) this.output.dataset.error = "" ; |
| 123 | else delete this.output.dataset.error; |
| 124 | } |
| 125 | |
| 126 | // load reads the published event without decrypting it. Decryption |
| 127 | // asks the signer, so it waits for the Decrypt to edit control. |
| 128 | async load() { |
| 129 | this.say("Reading encrypted list…" ); |
| 130 | const pubkey = await publicKey(this); |
| 131 | this.event = await readList(pubkey); |
| 132 | if (!this.event) { |
| 133 | this.say("No private relay list published yet." ); |
| 134 | return; |
| 135 | } |
| 136 | const control = this.querySelector("[data-decrypt]" ); |
| 137 | if (control) control.hidden = false; |
| 138 | this.say("An encrypted list is published. Decrypt to edit it, or publish new URLs to merge them." ); |
| 139 | } |
| 140 | |
| 141 | async reveal() { |
| 142 | const pubkey = await publicKey(this); |
| 143 | const event = this.event || (await readList(pubkey)); |
| 144 | if (!event) { |
| 145 | this.say("No private relay list published yet." ); |
| 146 | return; |
| 147 | } |
| 148 | const rows = JSON.parse(await decrypt(pubkey, event.content)); |
| 149 | if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array." ); |
| 150 | const urls = rows |
| 151 | .filter(row => Array.isArray(row) && row[0] === "g" && validURL(row[1])) |
| 152 | .map(row => relay(row[1])); |
| 153 | this.lines.value = urls.join("\n" ); |
| 154 | this.say("Loaded " + urls.length + " private relay" + (urls.length === 1 ? "" : "s" ) + "." ); |
| 155 | } |
| 156 | |
| 157 | async save() { |
| 158 | const rawURLs = this.lines.value |
| 159 | .split(/\r?\n/) |
| 160 | .map(value => value.trim()) |
| 161 | .filter(Boolean); |
| 162 | if (rawURLs.some(value => !validURL(value))) |
| 163 | throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments." ); |
| 164 | const urls = rawURLs.map(relay); |
| 165 | const unique = [...new Set(urls)]; |
| 166 | const pubkey = await publicKey(this); |
| 167 | const existing = await readList(pubkey); |
| 168 | let rows = []; |
| 169 | if (existing) { |
| 170 | rows = JSON.parse(await decrypt(pubkey, existing.content)); |
| 171 | if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array." ); |
| 172 | } |
| 173 | rows = mergeRows(rows, unique); |
| 174 | const content = await encrypt(pubkey, JSON.stringify(rows)); |
| 175 | const active = signer(); |
| 176 | if (!active?.signEvent) throw Error("Connect a signer first." ); |
| 177 | const event = await active.signEvent(wireEvent(content)); |
| 178 | if (!acceptEvent(event, pubkey) || event.content !== content || event.tags.length !== 0) |
| 179 | throw Error("The signer returned an invalid private relay list event." ); |
| 180 | await publish(event); |
| 181 | this.say("Published " + unique.length + " private relay" + (unique.length === 1 ? "" : "s" ) + "." ); |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | const api = Object.freeze({ |
| 186 | validURL, |
| 187 | canonicalURL: relay, |
| 188 | mergeRows, |
| 189 | encrypt, |
| 190 | decrypt, |
| 191 | wireEvent, |
| 192 | acceptEvent, |
| 193 | readList |
| 194 | }); |
| 195 | window.tiny = window.tiny || {}; |
| 196 | window.tiny.files = {...window.tiny.files, privateServices: api}; |
| 197 | // In-place navigation can request this bundle after a page already loaded |
| 198 | // it. Keep the browser's one-definition rule intact while refreshing the |
| 199 | // shared API above. |
| 200 | if (!customElements.get("private-services" )) customElements.define("private-services" , PrivateServices); |
| 201 | })(); |