tinyrelayfile
tinyrelay/tinyclient/private-services.js/

tinyclient/private-services.js raw
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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs