clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / tinyclient / private-services.js
private-services.js 7.7K
1// PrivateServices edits the encrypted kind 10318 relay list. NIP-44 encryption2// and event signing stay with the user's signer.3(() => {4 "use strict";56 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 showing21 // 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 body31 });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 content81 });8283 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 }111112 // whenSigned runs once a signer is available. The bridge announces a113 // 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 }119120 say(text, error) {121 this.output.textContent = text;122 if (error) this.output.dataset.error = "";123 else delete this.output.dataset.error;124 }125126 // load reads the published event without decrypting it. Decryption127 // 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 }140141 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 = rows151 .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 }156157 async save() {158 const rawURLs = this.lines.value159 .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 }184185 const api = Object.freeze({186 validURL,187 canonicalURL: relay,188 mergeRows,189 encrypt,190 decrypt,191 wireEvent,192 acceptEvent,193 readList194 });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 loaded198 // it. Keep the browser's one-definition rule intact while refreshing the199 // shared API above.200 if (!customElements.get("private-services")) customElements.define("private-services", PrivateServices);201})();