tinyrelaycommit

Commit 761810cc7c63

browse files at this commit | history

1diff --git a/internal/webui/browse.html b/internal/webui/browse.html
2index e1d412b..6219251 100644
3--- a/internal/webui/browse.html
4+++ b/internal/webui/browse.html
5@@ -58,7 +58,7 @@
6 {{else}}{{$open := approvalViews .Event .URL "open"}}{{$done := approvalViews .Event .URL "answered" "expired"}}{{if $open}}{{range $item := $open}}<approval-item id="approval-{{$item.ID}}" data-type="{{$item.Type}}" data-state="{{$item.State}}">
7 <header>{{template "nostrKey" $item.Asker}} in {{$item.Where}} | <time datetime="{{datetime $item.CreatedAt}}">{{when $item.CreatedAt}}</time>{{if $item.Expires}} | expires <time datetime="{{datetime $item.Expires}}">{{when $item.Expires}}</time>{{end}}</header>
8 <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}
9-<footer>{{if ne $item.Type "question"}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="answer" value="+">Approve</button> <button name="answer" value="-">Deny</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>
10+<footer>{{if ne $item.Type "question"}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">Approve</button> <button name="reaction" value="-">Deny</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>
11 </approval-item>
12 {{end}}{{else}}<p>Nothing waits for your decision.</p>
13 {{end}}{{if $done}}<h3>Answered</h3>
14diff --git a/internal/webui/components.js b/internal/webui/components.js
15index 9b63c1a..71f7915 100644
16--- a/internal/webui/components.js
17+++ b/internal/webui/components.js
18@@ -30,10 +30,16 @@
19 // <nostr-compose kind="…" [coordinate="30617:…"] [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>
20 // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and
21 // posts it to the relay. A reply outside a repository omits the coordinate.
22+// <wiki-compose name="…" [author="…" coordinate="30818:…" event="…"]>
23+// Signs a NIP-54 article and posts it. Someone other than the author
24+// publishes a fork of the version in view; a "propose" button also signs
25+// a merge request to that author.
26 // <nostr-react event="…" pubkey="…" [kind="…"]>
27-// Answers a request for a decision with a signed kind 7 reaction: the
28-// Approve button sends +, Deny sends -. The prompt method asks for a
29-// visible confirmation first, which the answer link from a notification uses.
30+// Signs a NIP-25 reaction to one event; the pressed button carries "+"
31+// or "-". A request for a decision is approved or denied and a wiki
32+// merge request accepted or rejected this way. The prompt method asks
33+// for a visible confirmation first, which the answer link from a
34+// notification uses.
35 // <approval-item id="approval-…">
36 // One request on the Approvals page. When the page opens with the item's
37 // id and an answer in the query, it scrolls into view and offers that answer.
38@@ -74,6 +80,8 @@
39 event.preventDefault();
40 if (this.submitting) return;
41 this.submitting = true;
42+ // The button that sent the form, for elements whose buttons differ.
43+ this.submitter = event.submitter || null;
44 this.busy(true);
45 try {
46 await this.submit(form);
47@@ -562,11 +570,9 @@
48 }
49 }
50
51- // NostrReact answers a request for a decision. The reaction names the
52- // request with e and the asker with p, so the asker's own subscription
53- // sees the answer. Nothing is signed without a press: the buttons submit
54- // the form, and prompt() asks once more before signing on behalf of a
55- // notification action.
56+ // publishSigned asks the signer for a signature, checks that what came
57+ // back is the event that was requested, since extensions may edit input,
58+ // then posts it once and reports the relay's answer.
59 const publishSigned = async unsigned => {
60 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
61 const expected = JSON.stringify(unsigned);
62@@ -574,11 +580,22 @@
63 const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});
64 if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");
65 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
66- const result = await response.json();
67- if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");
68+ let result = null;
69+ try { result = await response.json(); } catch {}
70+ if (!response.ok || result?.accepted !== true) throw Error(result?.error || result?.message || "The relay rejected the event.");
71 return event;
72 };
73+ const unixNow = () => Math.floor(Date.now() / 1000);
74+
75+ // NostrReact signs a NIP-25 reaction to one event: "+" or "-" from the
76+ // pressed button. The reaction names the event with e and its author with
77+ // p, so the author's own subscription sees the answer. A request for a
78+ // decision is approved or denied this way and a wiki merge request
79+ // accepted or rejected. Nothing is signed without a press: the buttons
80+ // submit the form, and prompt() asks once more before signing on behalf of
81+ // a notification action.
82 const decisions = {"+": "approve", "-": "deny"};
83+ const reactionWords = {"+": "Approved.", "-": "Denied.", accept: "Accepted.", approve: "Approved.", reject: "Rejected.", deny: "Denied."};
84 class NostrReact extends FormElement {
85 connectedCallback() {
86 if (this.form) return;
87@@ -586,12 +603,12 @@
88 if (this.listening) return;
89 this.listening = true;
90 this.addEventListener("click", event => {
91- const button = event.target.closest("button[name=answer]");
92+ const button = event.target.closest("button[name=reaction]");
93 if (button) this.choice = button.value;
94 });
95 }
96
97- async submit(form, choice = this.choice) {
98+ async submit(form, choice = this.choice ?? this.submitter?.value ?? form?.elements?.reaction?.value) {
99 const id = this.getAttribute("event"), pubkey = this.getAttribute("pubkey");
100 if (!isHex64(id) || !isHex64(pubkey)) throw Error("The request address is missing.");
101 if (!(choice in decisions)) throw Error("Choose Approve or Deny.");
102@@ -599,9 +616,11 @@
103 const kind = this.getAttribute("kind");
104 if (/^\d+$/.test(kind || "")) tags.push(["k", kind]);
105 this.report("Signing…");
106- await publishSigned({kind: 7, created_at: Math.floor(Date.now() / 1000), tags, content: choice});
107+ await publishSigned({kind: 7, created_at: unixNow(), tags, content: choice});
108+ const label = (this.submitter?.textContent || "").trim().toLowerCase();
109 this.choice = null;
110- this.report(choice === "+" ? "Approved." : "Denied.");
111+ this.submitter = null;
112+ this.report(reactionWords[label] || reactionWords[choice]);
113 await tiny.navigate?.((globalThis.location?.href || "").split("?", 1)[0]);
114 }
115
116@@ -632,6 +651,60 @@
117 }
118 }
119
120+ // WikiCompose publishes a NIP-54 article. When the signer is not the
121+ // author of the version in view, the article is a fork of that version,
122+ // and "propose" also signs a kind 818 merge request to that author. The
123+ // name follows the title on a new page until it is edited by hand.
124+ class WikiCompose extends FormElement {
125+ connectedCallback() {
126+ super.connectedCallback();
127+ const title = this.form?.elements?.title, name = this.form?.elements?.name;
128+ if (this.wired === this.form || !title || !name) return;
129+ this.wired = this.form;
130+ let follow = !this.getAttribute("name");
131+ name.addEventListener("input", () => { follow = name.value.trim() === ""; });
132+ name.addEventListener("change", () => { name.value = tiny.util.wikiName(name.value); });
133+ title.addEventListener("input", () => { if (follow) name.value = tiny.util.wikiName(title.value); });
134+ }
135+
136+ async submit(form) {
137+ const value = key => form.elements[key]?.value.trim() || "";
138+ const title = value("title"), d = tiny.util.wikiName(value("name") || title), summary = value("summary");
139+ const content = form.elements.content?.value || "";
140+ if (!title) throw Error("Enter a title.");
141+ if (!d) throw Error("Enter a name with at least one letter or digit.");
142+ if (!content.trim()) throw Error("Enter the content.");
143+ const action = this.submitter?.value || "publish";
144+ const author = this.getAttribute("author") || "", coordinate = this.getAttribute("coordinate") || "", base = this.getAttribute("event") || "";
145+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
146+ const pubkey = typeof window.nostr.getPublicKey === "function" ? await window.nostr.getPublicKey() : "";
147+ const forking = isHex64(author) && author !== pubkey;
148+ if (action === "propose" && !forking) throw Error("This is your own version; publish it instead.");
149+ const tags = [["d", d], ["title", title]];
150+ if (summary) tags.push(["summary", summary]);
151+ if (forking) {
152+ if (!/^30818:[0-9a-f]{64}:.+$/.test(coordinate) || !isHex64(base)) throw Error("The version to fork is missing.");
153+ tags.push(["a", coordinate, "", "fork"], ["e", base, "", "fork"]);
154+ }
155+ const version = await publishSigned({kind: 30818, created_at: unixNow(), tags, content});
156+ if (action === "propose") {
157+ const request = {kind: 818, created_at: unixNow(), tags: [["a", coordinate], ["e", version.id, "", "source"], ["e", base], ["p", author]], content: summary || "Proposed version of " + title};
158+ await publishSigned(request);
159+ this.report("Published and proposed to " + author.slice(0, 12) + ".");
160+ } else {
161+ this.report("Published.");
162+ }
163+ const path = "/wiki/" + encodeURIComponent(d);
164+ const href = tiny.localPath ? tiny.localPath(path) : path;
165+ if (tiny.navigate) {
166+ globalThis.history?.pushState?.({}, "", href);
167+ await tiny.navigate(href);
168+ } else {
169+ globalThis.location?.assign?.(href);
170+ }
171+ }
172+ }
173+
174 // ApprovalItem reacts to the query a notification action opens the page
175 // with: id names the request and answer is approve, deny or reply.
176 class ApprovalItem extends HTMLElement {
177@@ -1100,6 +1173,7 @@
178 customElements.define("agent-grant", AgentGrant);
179 customElements.define("nostr-react", NostrReact);
180 customElements.define("approval-item", ApprovalItem);
181+ customElements.define("wiki-compose", WikiCompose);
182 customElements.define("push-toggle", PushToggle);
183 customElements.define("share-link", ShareLink);
184 customElements.define("nostr-key", NostrKey);
185@@ -1130,7 +1204,7 @@
186 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
187 return root ? url.pathname.slice(root.length) || "/" : url.pathname;
188 };
189- const allowedRoute = path => /^(?:\/(?:inbox|approvals|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || "");
190+ const allowedRoute = path => /^(?:\/(?:inbox|approvals|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki)\/.+)$/.test(path || "");
191 let navigationSerial = 0, activeAbort;
192 const streams = new Set();
193 const closeStreams = () => {
194@@ -1145,7 +1219,7 @@
195 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));
196 window.addEventListener("pagehide", closeStreams);
197 const repairComponents = () => {
198- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react").forEach(node => {
199+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose").forEach(node => {
200 if (node.form?.isConnected) return;
201 node.form = null;
202 node.output = null;
203diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js
204index 748e838..e807137 100644
205--- a/internal/webui/tiny.js
206+++ b/internal/webui/tiny.js
207@@ -86,7 +86,21 @@
208 const checksum = Array.from({length: 6}, (_, index) => (polymod >>> (5 * (5 - index))) & 31);
209 return prefix + "1" + words.concat(checksum).map(word => bech32Charset[word]).join("");
210 };
211- tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode});
212+ // wikiName turns a title into a NIP-54 page name the way the relay does:
213+ // lowercase, whitespace as hyphens, letters, digits and marks kept,
214+ // everything else dropped, hyphens collapsed and trimmed at both ends.
215+ const wikiName = title => {
216+ let out = "";
217+ for (const ch of String(title ?? "").toLowerCase()) {
218+ if (/[\s\-_]/u.test(ch)) {
219+ if (out && !out.endsWith("-")) out += "-";
220+ } else if (/[\p{L}\p{Nd}\p{M}]/u.test(ch)) {
221+ out += ch;
222+ }
223+ }
224+ return out.replace(/^-+|-+$/g, "");
225+ };
226+ tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode, wikiName});
227 // signer returns the active signer: a resumed remote signer first, then a
228 // NIP-07 extension.
229 tiny.signer = () => globalThis.tinySigner || globalThis.nostr;
230diff --git a/package.json b/package.json
231index db1d6a0..2bbbdd9 100644
232--- a/package.json
233+++ b/package.json
234@@ -10,7 +10,8 @@
235 "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs scripts/tiny-namespace.test.mjs",
236 "test:collaboration": "node --test scripts/collaboration-compose.test.mjs",
237 "test:agents": "node --test scripts/agent-grant.test.mjs",
238- "test:approvals": "node --test scripts/approvals.test.mjs"
239+ "test:approvals": "node --test scripts/approvals.test.mjs",
240+ "test:wiki": "node --test scripts/wiki-compose.test.mjs"
241 },
242 "devDependencies": {
243 "@noble/hashes": "2.4.0",
244diff --git a/scripts/wiki-compose.test.mjs b/scripts/wiki-compose.test.mjs
245new file mode 100644
246index 0000000..0f52217
247--- /dev/null
248+++ b/scripts/wiki-compose.test.mjs
249@@ -0,0 +1,163 @@
250+import test from "node:test";
251+import assert from "node:assert/strict";
252+import fs from "node:fs";
253+import vm from "node:vm";
254+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools";
255+
256+const source = fs.readFileSync("internal/webui/components.js", "utf8");
257+const tinySource = fs.readFileSync("internal/webui/tiny.js", "utf8");
258+const wikiSource = source.slice(source.indexOf(" const publishSigned = "), source.indexOf(" // ApprovalItem reacts"));
259+const isHex64 = value => /^[0-9a-f]{64}$/.test(value || "");
260+
261+const util = (() => {
262+ const sandbox = {TextEncoder, TextDecoder, URL, crypto: {subtle: {}}, document: {}};
263+ sandbox.globalThis = sandbox;
264+ vm.runInNewContext(tinySource, sandbox);
265+ return sandbox.tiny.util;
266+})();
267+
268+test("wikiName follows the relay's NIP-54 normalization", () => {
269+ for (const [title, name] of [
270+ ["What's Up?", "whats-up"],
271+ ["日本語 Article", "日本語-article"],
272+ ["Москва", "москва"],
273+ [" --Hello__World-- ", "hello-world"],
274+ ["Release notes 1.4", "release-notes-14"],
275+ ["Files & Private Repositories", "files-private-repositories"],
276+ ["café au lait", "café-au-lait"],
277+ ["***", ""],
278+ ["", ""]
279+ ]) {
280+ assert.equal(util.wikiName(title), name, title);
281+ }
282+ assert.equal(util.wikiName(undefined), "");
283+});
284+
285+// setup runs one element class from components.js against fakes for the
286+// signer, the relay and the form, the way collaboration-compose.test does.
287+function setup(name, attributes, values = {}, options = {}) {
288+ const secret = generateSecretKey(), pubkey = getPublicKey(secret);
289+ const sent = [], signed = [];
290+ class FormElement {
291+ getAttribute(key) { return attributes[key] ?? null; }
292+ report(message) { this.message = message; }
293+ }
294+ const window = {NostrSigner: {verifyEvent}, nostr: {
295+ getPublicKey: async () => pubkey,
296+ signEvent: async event => {
297+ signed.push(structuredClone(event));
298+ if (options.change) event.content = "changed";
299+ return finalizeEvent(structuredClone(event), secret);
300+ }
301+ }};
302+ const tiny = {util, navigated: [], signedFetch: async (path, method, body) => {
303+ sent.push({path, method, event: JSON.parse(body)});
304+ return Response.json(options.results?.[sent.length - 1] ?? options.result ?? {accepted: true});
305+ }, navigate: async href => { tiny.navigated.push(href); }};
306+ const Constructor = vm.runInNewContext(`${wikiSource}\n${name}`, {FormElement, window, tiny, URL, isHex64, Date, JSON, encodeURIComponent});
307+ const component = new Constructor();
308+ component.submitter = options.submitter ? {value: options.submitter, textContent: options.label || ""} : null;
309+ const form = {elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}]))};
310+ return {component, form, sent, signed, tiny, pubkey};
311+}
312+
313+const author = "a".repeat(64), base = "b".repeat(64), mergeID = "c".repeat(64);
314+const coordinate = `30818:${author}:release-notes-1-4`;
315+const page = {name: "release-notes-1-4", author, coordinate, event: base};
316+
317+test("a new page signs d, title and summary and opens the page", async () => {
318+ const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, {title: "What's Up?", name: "", summary: "A greeting", content: "# Hi\n\nHello."});
319+ await s.component.submit(s.form);
320+ assert.equal(s.sent.length, 1);
321+ const event = s.sent[0].event;
322+ assert.equal(s.sent[0].path, "/events");
323+ assert.equal(event.kind, 30818);
324+ assert.deepEqual(event.tags, [["d", "whats-up"], ["title", "What's Up?"], ["summary", "A greeting"]]);
325+ assert.equal(event.content, "# Hi\n\nHello.");
326+ assert.equal(verifyEvent(event), true);
327+ assert.equal(s.component.message, "Published.");
328+ assert.deepEqual(s.tiny.navigated, ["/wiki/whats-up"]);
329+});
330+
331+test("the name field wins over the title and is normalized", async () => {
332+ const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, {title: "Bitcoin", name: " BTC Coin ", content: "Money."});
333+ await s.component.submit(s.form);
334+ assert.deepEqual(s.sent[0].event.tags, [["d", "btc-coin"], ["title", "Bitcoin"]]);
335+});
336+
337+test("another signer publishes a fork of the version in view", async () => {
338+ const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", summary: "", content: "Rewritten."});
339+ await s.component.submit(s.form);
340+ assert.equal(s.sent.length, 1);
341+ assert.deepEqual(s.sent[0].event.tags, [["d", "release-notes-1-4"], ["title", "Release notes 1.4"], ["a", coordinate, "", "fork"], ["e", base, "", "fork"]]);
342+ assert.notEqual(s.sent[0].event.pubkey, author);
343+});
344+
345+test("propose publishes the fork and then a merge request to the author", async () => {
346+ const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", summary: "Fix the upload section", content: "Rewritten."}, {submitter: "propose"});
347+ await s.component.submit(s.form);
348+ assert.equal(s.sent.length, 2);
349+ const [version, request] = s.sent.map(item => item.event);
350+ assert.equal(version.kind, 30818);
351+ assert.equal(request.kind, 818);
352+ assert.deepEqual(request.tags, [["a", coordinate], ["e", version.id, "", "source"], ["e", base], ["p", author]]);
353+ assert.equal(request.content, "Fix the upload section");
354+ assert.equal(verifyEvent(request), true);
355+ assert.match(s.component.message, /proposed to aaaaaaaaaaaa/);
356+ assert.deepEqual(s.tiny.navigated, ["/wiki/release-notes-1-4"]);
357+});
358+
359+test("the author's own edit carries no fork tags and cannot be proposed", async () => {
360+ const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", content: "Edited."});
361+ s.component.getAttribute = key => ({...page, author: s.pubkey, coordinate: `30818:${s.pubkey}:release-notes-1-4`})[key] ?? null;
362+ await s.component.submit(s.form);
363+ assert.deepEqual(s.sent[0].event.tags, [["d", "release-notes-1-4"], ["title", "Release notes 1.4"]]);
364+ const own = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", content: "Edited."}, {submitter: "propose"});
365+ own.component.getAttribute = key => ({...page, author: own.pubkey})[key] ?? null;
366+ await assert.rejects(own.component.submit(own.form), /your own version/);
367+ assert.equal(own.sent.length, 0);
368+});
369+
370+test("missing fields, a changed signature and a relay refusal stop before or after one event", async () => {
371+ for (const [values, message] of [[{title: "", name: "", content: "x"}, /title/], [{title: "***", name: "", content: "x"}, /letter or digit/], [{title: "T", name: "", content: " "}, /content/]]) {
372+ const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, values);
373+ await assert.rejects(s.component.submit(s.form), message);
374+ assert.equal(s.signed.length, 0);
375+ }
376+ const changed = setup("WikiCompose", page, {title: "T", name: "t", content: "x"}, {change: true});
377+ await assert.rejects(changed.component.submit(changed.form), /changed/);
378+ assert.equal(changed.sent.length, 0);
379+ const refused = setup("WikiCompose", page, {title: "T", name: "t", content: "x"}, {submitter: "propose", results: [{accepted: false, message: "blocked"}]});
380+ await assert.rejects(refused.component.submit(refused.form), /blocked/);
381+ assert.equal(refused.sent.length, 1);
382+ assert.deepEqual(refused.tiny.navigated, []);
383+});
384+
385+test("nostr-react signs the reaction the pressed button carries", async () => {
386+ for (const [reaction, message] of [["+", "Accepted."], ["-", "Rejected."]]) {
387+ const s = setup("NostrReact", {event: mergeID, pubkey: author, kind: "818"}, {}, {submitter: reaction, label: reaction === "+" ? "Accept" : "Reject"});
388+ await s.component.submit(s.form);
389+ const event = s.sent[0].event;
390+ assert.equal(event.kind, 7);
391+ assert.equal(event.content, reaction);
392+ assert.deepEqual(event.tags, [["e", mergeID, "", author], ["p", author], ["k", "818"]]);
393+ assert.equal(verifyEvent(event), true);
394+ assert.equal(s.component.message, message);
395+ }
396+ const plain = setup("NostrReact", {event: mergeID, pubkey: author}, {}, {submitter: "+"});
397+ await plain.component.submit(plain.form);
398+ assert.deepEqual(plain.sent[0].event.tags, [["e", mergeID, "", author], ["p", author]]);
399+});
400+
401+test("nostr-react refuses unknown reactions and bad addresses before signing", async () => {
402+ for (const [attributes, submitter, message] of [
403+ [{event: mergeID, pubkey: author}, "meh", /Approve or Deny/],
404+ [{event: "short", pubkey: author}, "+", /missing/],
405+ [{event: mergeID, pubkey: ""}, "-", /missing/]
406+ ]) {
407+ const s = setup("NostrReact", attributes, {}, {submitter});
408+ await assert.rejects(s.component.submit(s.form), message);
409+ assert.equal(s.signed.length, 0);
410+ assert.equal(s.sent.length, 0);
411+ }
412+});

About

ownerc01503e8035f
visibilitypublic
head761810cc7c63

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs