- commit
- 761810cc7c6330e0e2b05dab7137452ea772073c
- parent
- d2acf557357353282d0666d529579637a1b73ab0
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 2 | +- |
| M | internal/ | 106 | +++++++++++++++++--- |
| M | internal/ | 16 | +++- |
| M | package.json | 3 | +- |
| A | scripts/ | 163 | ++++++++++++++++++++++++++++++ |
@@ -58,7 +58,7 @@
5858 {{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}}">5959 <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>6060 <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}61-<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>61+<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>6262 </approval-item>6363 {{end}}{{else}}<p>Nothing waits for your decision.</p>6464 {{end}}{{if $done}}<h3>Answered</h3>@@ -30,10 +30,16 @@
3030 // <nostr-compose kind="…" [coordinate="30617:…"] [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>3131 // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and3232 // posts it to the relay. A reply outside a repository omits the coordinate.33+// <wiki-compose name="…" [author="…" coordinate="30818:…" event="…"]>34+// Signs a NIP-54 article and posts it. Someone other than the author35+// publishes a fork of the version in view; a "propose" button also signs36+// a merge request to that author.3337 // <nostr-react event="…" pubkey="…" [kind="…"]>34-// Answers a request for a decision with a signed kind 7 reaction: the35-// Approve button sends +, Deny sends -. The prompt method asks for a36-// visible confirmation first, which the answer link from a notification uses.38+// Signs a NIP-25 reaction to one event; the pressed button carries "+"39+// or "-". A request for a decision is approved or denied and a wiki40+// merge request accepted or rejected this way. The prompt method asks41+// for a visible confirmation first, which the answer link from a42+// notification uses.3743 // <approval-item id="approval-…">3844 // One request on the Approvals page. When the page opens with the item's3945 // id and an answer in the query, it scrolls into view and offers that answer.@@ -74,6 +80,8 @@
7480 event.preventDefault();7581 if (this.submitting) return;7682 this.submitting = true;83+ // The button that sent the form, for elements whose buttons differ.84+ this.submitter = event.submitter || null;7785 this.busy(true);7886 try {7987 await this.submit(form);@@ -562,11 +570,9 @@
562570 }563571 }564572 565- // NostrReact answers a request for a decision. The reaction names the566- // request with e and the asker with p, so the asker's own subscription567- // sees the answer. Nothing is signed without a press: the buttons submit568- // the form, and prompt() asks once more before signing on behalf of a569- // notification action.573+ // publishSigned asks the signer for a signature, checks that what came574+ // back is the event that was requested, since extensions may edit input,575+ // then posts it once and reports the relay's answer.570576 const publishSigned = async unsigned => {571577 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");572578 const expected = JSON.stringify(unsigned);@@ -574,11 +580,22 @@
574580 const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});575581 if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");576582 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});577- const result = await response.json();578- if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");583+ let result = null;584+ try { result = await response.json(); } catch {}585+ if (!response.ok || result?.accepted !== true) throw Error(result?.error || result?.message || "The relay rejected the event.");579586 return event;580587 };588+ const unixNow = () => Math.floor(Date.now() / 1000);589+590+ // NostrReact signs a NIP-25 reaction to one event: "+" or "-" from the591+ // pressed button. The reaction names the event with e and its author with592+ // p, so the author's own subscription sees the answer. A request for a593+ // decision is approved or denied this way and a wiki merge request594+ // accepted or rejected. Nothing is signed without a press: the buttons595+ // submit the form, and prompt() asks once more before signing on behalf of596+ // a notification action.581597 const decisions = {"+": "approve", "-": "deny"};598+ const reactionWords = {"+": "Approved.", "-": "Denied.", accept: "Accepted.", approve: "Approved.", reject: "Rejected.", deny: "Denied."};582599 class NostrReact extends FormElement {583600 connectedCallback() {584601 if (this.form) return;@@ -586,12 +603,12 @@
586603 if (this.listening) return;587604 this.listening = true;588605 this.addEventListener("click", event => {589- const button = event.target.closest("button[name=answer]");606+ const button = event.target.closest("button[name=reaction]");590607 if (button) this.choice = button.value;591608 });592609 }593610 594- async submit(form, choice = this.choice) {611+ async submit(form, choice = this.choice ?? this.submitter?.value ?? form?.elements?.reaction?.value) {595612 const id = this.getAttribute("event"), pubkey = this.getAttribute("pubkey");596613 if (!isHex64(id) || !isHex64(pubkey)) throw Error("The request address is missing.");597614 if (!(choice in decisions)) throw Error("Choose Approve or Deny.");@@ -599,9 +616,11 @@
599616 const kind = this.getAttribute("kind");600617 if (/^\d+$/.test(kind || "")) tags.push(["k", kind]);601618 this.report("Signing…");602- await publishSigned({kind: 7, created_at: Math.floor(Date.now() / 1000), tags, content: choice});619+ await publishSigned({kind: 7, created_at: unixNow(), tags, content: choice});620+ const label = (this.submitter?.textContent || "").trim().toLowerCase();603621 this.choice = null;604- this.report(choice === "+" ? "Approved." : "Denied.");622+ this.submitter = null;623+ this.report(reactionWords[label] || reactionWords[choice]);605624 await tiny.navigate?.((globalThis.location?.href || "").split("?", 1)[0]);606625 }607626 @@ -632,6 +651,60 @@
632651 }633652 }634653 654+ // WikiCompose publishes a NIP-54 article. When the signer is not the655+ // author of the version in view, the article is a fork of that version,656+ // and "propose" also signs a kind 818 merge request to that author. The657+ // name follows the title on a new page until it is edited by hand.658+ class WikiCompose extends FormElement {659+ connectedCallback() {660+ super.connectedCallback();661+ const title = this.form?.elements?.title, name = this.form?.elements?.name;662+ if (this.wired === this.form || !title || !name) return;663+ this.wired = this.form;664+ let follow = !this.getAttribute("name");665+ name.addEventListener("input", () => { follow = name.value.trim() === ""; });666+ name.addEventListener("change", () => { name.value = tiny.util.wikiName(name.value); });667+ title.addEventListener("input", () => { if (follow) name.value = tiny.util.wikiName(title.value); });668+ }669+670+ async submit(form) {671+ const value = key => form.elements[key]?.value.trim() || "";672+ const title = value("title"), d = tiny.util.wikiName(value("name") || title), summary = value("summary");673+ const content = form.elements.content?.value || "";674+ if (!title) throw Error("Enter a title.");675+ if (!d) throw Error("Enter a name with at least one letter or digit.");676+ if (!content.trim()) throw Error("Enter the content.");677+ const action = this.submitter?.value || "publish";678+ const author = this.getAttribute("author") || "", coordinate = this.getAttribute("coordinate") || "", base = this.getAttribute("event") || "";679+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");680+ const pubkey = typeof window.nostr.getPublicKey === "function" ? await window.nostr.getPublicKey() : "";681+ const forking = isHex64(author) && author !== pubkey;682+ if (action === "propose" && !forking) throw Error("This is your own version; publish it instead.");683+ const tags = [["d", d], ["title", title]];684+ if (summary) tags.push(["summary", summary]);685+ if (forking) {686+ if (!/^30818:[0-9a-f]{64}:.+$/.test(coordinate) || !isHex64(base)) throw Error("The version to fork is missing.");687+ tags.push(["a", coordinate, "", "fork"], ["e", base, "", "fork"]);688+ }689+ const version = await publishSigned({kind: 30818, created_at: unixNow(), tags, content});690+ if (action === "propose") {691+ const request = {kind: 818, created_at: unixNow(), tags: [["a", coordinate], ["e", version.id, "", "source"], ["e", base], ["p", author]], content: summary || "Proposed version of " + title};692+ await publishSigned(request);693+ this.report("Published and proposed to " + author.slice(0, 12) + ".");694+ } else {695+ this.report("Published.");696+ }697+ const path = "/wiki/" + encodeURIComponent(d);698+ const href = tiny.localPath ? tiny.localPath(path) : path;699+ if (tiny.navigate) {700+ globalThis.history?.pushState?.({}, "", href);701+ await tiny.navigate(href);702+ } else {703+ globalThis.location?.assign?.(href);704+ }705+ }706+ }707+635708 // ApprovalItem reacts to the query a notification action opens the page636709 // with: id names the request and answer is approve, deny or reply.637710 class ApprovalItem extends HTMLElement {@@ -1100,6 +1173,7 @@
11001173 customElements.define("agent-grant", AgentGrant);11011174 customElements.define("nostr-react", NostrReact);11021175 customElements.define("approval-item", ApprovalItem);1176+ customElements.define("wiki-compose", WikiCompose);11031177 customElements.define("push-toggle", PushToggle);11041178 customElements.define("share-link", ShareLink);11051179 customElements.define("nostr-key", NostrKey);@@ -1130,7 +1204,7 @@
11301204 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;11311205 return root ? url.pathname.slice(root.length) || "/" : url.pathname;11321206 };1133- 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 || "");1207+ 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 || "");11341208 let navigationSerial = 0, activeAbort;11351209 const streams = new Set();11361210 const closeStreams = () => {@@ -1145,7 +1219,7 @@
11451219 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));11461220 window.addEventListener("pagehide", closeStreams);11471221 const repairComponents = () => {1148- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react").forEach(node => {1222+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose").forEach(node => {11491223 if (node.form?.isConnected) return;11501224 node.form = null;11511225 node.output = null;@@ -86,7 +86,21 @@
8686 const checksum = Array.from({length: 6}, (_, index) => (polymod >>> (5 * (5 - index))) & 31);8787 return prefix + "1" + words.concat(checksum).map(word => bech32Charset[word]).join("");8888 };89- tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode});89+ // wikiName turns a title into a NIP-54 page name the way the relay does:90+ // lowercase, whitespace as hyphens, letters, digits and marks kept,91+ // everything else dropped, hyphens collapsed and trimmed at both ends.92+ const wikiName = title => {93+ let out = "";94+ for (const ch of String(title ?? "").toLowerCase()) {95+ if (/[\s\-_]/u.test(ch)) {96+ if (out && !out.endsWith("-")) out += "-";97+ } else if (/[\p{L}\p{Nd}\p{M}]/u.test(ch)) {98+ out += ch;99+ }100+ }101+ return out.replace(/^-+|-+$/g, "");102+ };103+ tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode, wikiName});90104 // signer returns the active signer: a resumed remote signer first, then a91105 // NIP-07 extension.92106 tiny.signer = () => globalThis.tinySigner || globalThis.nostr;@@ -10,7 +10,8 @@
1010 "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",1111 "test:collaboration": "node --test scripts/collaboration-compose.test.mjs",1212 "test:agents": "node --test scripts/agent-grant.test.mjs",13- "test:approvals": "node --test scripts/approvals.test.mjs"13+ "test:approvals": "node --test scripts/approvals.test.mjs",14+ "test:wiki": "node --test scripts/wiki-compose.test.mjs"1415 },1516 "devDependencies": {1617 "@noble/hashes": "2.4.0",@@ -0,0 +1,163 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools";6+7+const source = fs.readFileSync("internal/webui/components.js", "utf8");8+const tinySource = fs.readFileSync("internal/webui/tiny.js", "utf8");9+const wikiSource = source.slice(source.indexOf(" const publishSigned = "), source.indexOf(" // ApprovalItem reacts"));10+const isHex64 = value => /^[0-9a-f]{64}$/.test(value || "");11+12+const util = (() => {13+ const sandbox = {TextEncoder, TextDecoder, URL, crypto: {subtle: {}}, document: {}};14+ sandbox.globalThis = sandbox;15+ vm.runInNewContext(tinySource, sandbox);16+ return sandbox.tiny.util;17+})();18+19+test("wikiName follows the relay's NIP-54 normalization", () => {20+ for (const [title, name] of [21+ ["What's Up?", "whats-up"],22+ ["日本語 Article", "日本語-article"],23+ ["Москва", "москва"],24+ [" --Hello__World-- ", "hello-world"],25+ ["Release notes 1.4", "release-notes-14"],26+ ["Files & Private Repositories", "files-private-repositories"],27+ ["café au lait", "café-au-lait"],28+ ["***", ""],29+ ["", ""]30+ ]) {31+ assert.equal(util.wikiName(title), name, title);32+ }33+ assert.equal(util.wikiName(undefined), "");34+});35+36+// setup runs one element class from components.js against fakes for the37+// signer, the relay and the form, the way collaboration-compose.test does.38+function setup(name, attributes, values = {}, options = {}) {39+ const secret = generateSecretKey(), pubkey = getPublicKey(secret);40+ const sent = [], signed = [];41+ class FormElement {42+ getAttribute(key) { return attributes[key] ?? null; }43+ report(message) { this.message = message; }44+ }45+ const window = {NostrSigner: {verifyEvent}, nostr: {46+ getPublicKey: async () => pubkey,47+ signEvent: async event => {48+ signed.push(structuredClone(event));49+ if (options.change) event.content = "changed";50+ return finalizeEvent(structuredClone(event), secret);51+ }52+ }};53+ const tiny = {util, navigated: [], signedFetch: async (path, method, body) => {54+ sent.push({path, method, event: JSON.parse(body)});55+ return Response.json(options.results?.[sent.length - 1] ?? options.result ?? {accepted: true});56+ }, navigate: async href => { tiny.navigated.push(href); }};57+ const Constructor = vm.runInNewContext(`${wikiSource}\n${name}`, {FormElement, window, tiny, URL, isHex64, Date, JSON, encodeURIComponent});58+ const component = new Constructor();59+ component.submitter = options.submitter ? {value: options.submitter, textContent: options.label || ""} : null;60+ const form = {elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}]))};61+ return {component, form, sent, signed, tiny, pubkey};62+}63+64+const author = "a".repeat(64), base = "b".repeat(64), mergeID = "c".repeat(64);65+const coordinate = `30818:${author}:release-notes-1-4`;66+const page = {name: "release-notes-1-4", author, coordinate, event: base};67+68+test("a new page signs d, title and summary and opens the page", async () => {69+ const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, {title: "What's Up?", name: "", summary: "A greeting", content: "# Hi\n\nHello."});70+ await s.component.submit(s.form);71+ assert.equal(s.sent.length, 1);72+ const event = s.sent[0].event;73+ assert.equal(s.sent[0].path, "/events");74+ assert.equal(event.kind, 30818);75+ assert.deepEqual(event.tags, [["d", "whats-up"], ["title", "What's Up?"], ["summary", "A greeting"]]);76+ assert.equal(event.content, "# Hi\n\nHello.");77+ assert.equal(verifyEvent(event), true);78+ assert.equal(s.component.message, "Published.");79+ assert.deepEqual(s.tiny.navigated, ["/wiki/whats-up"]);80+});81+82+test("the name field wins over the title and is normalized", async () => {83+ const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, {title: "Bitcoin", name: " BTC Coin ", content: "Money."});84+ await s.component.submit(s.form);85+ assert.deepEqual(s.sent[0].event.tags, [["d", "btc-coin"], ["title", "Bitcoin"]]);86+});87+88+test("another signer publishes a fork of the version in view", async () => {89+ const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", summary: "", content: "Rewritten."});90+ await s.component.submit(s.form);91+ assert.equal(s.sent.length, 1);92+ assert.deepEqual(s.sent[0].event.tags, [["d", "release-notes-1-4"], ["title", "Release notes 1.4"], ["a", coordinate, "", "fork"], ["e", base, "", "fork"]]);93+ assert.notEqual(s.sent[0].event.pubkey, author);94+});95+96+test("propose publishes the fork and then a merge request to the author", async () => {97+ const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", summary: "Fix the upload section", content: "Rewritten."}, {submitter: "propose"});98+ await s.component.submit(s.form);99+ assert.equal(s.sent.length, 2);100+ const [version, request] = s.sent.map(item => item.event);101+ assert.equal(version.kind, 30818);102+ assert.equal(request.kind, 818);103+ assert.deepEqual(request.tags, [["a", coordinate], ["e", version.id, "", "source"], ["e", base], ["p", author]]);104+ assert.equal(request.content, "Fix the upload section");105+ assert.equal(verifyEvent(request), true);106+ assert.match(s.component.message, /proposed to aaaaaaaaaaaa/);107+ assert.deepEqual(s.tiny.navigated, ["/wiki/release-notes-1-4"]);108+});109+110+test("the author's own edit carries no fork tags and cannot be proposed", async () => {111+ const s = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", content: "Edited."});112+ s.component.getAttribute = key => ({...page, author: s.pubkey, coordinate: `30818:${s.pubkey}:release-notes-1-4`})[key] ?? null;113+ await s.component.submit(s.form);114+ assert.deepEqual(s.sent[0].event.tags, [["d", "release-notes-1-4"], ["title", "Release notes 1.4"]]);115+ const own = setup("WikiCompose", page, {title: "Release notes 1.4", name: "release-notes-1-4", content: "Edited."}, {submitter: "propose"});116+ own.component.getAttribute = key => ({...page, author: own.pubkey})[key] ?? null;117+ await assert.rejects(own.component.submit(own.form), /your own version/);118+ assert.equal(own.sent.length, 0);119+});120+121+test("missing fields, a changed signature and a relay refusal stop before or after one event", async () => {122+ for (const [values, message] of [[{title: "", name: "", content: "x"}, /title/], [{title: "***", name: "", content: "x"}, /letter or digit/], [{title: "T", name: "", content: " "}, /content/]]) {123+ const s = setup("WikiCompose", {name: "", author: "", coordinate: "", event: ""}, values);124+ await assert.rejects(s.component.submit(s.form), message);125+ assert.equal(s.signed.length, 0);126+ }127+ const changed = setup("WikiCompose", page, {title: "T", name: "t", content: "x"}, {change: true});128+ await assert.rejects(changed.component.submit(changed.form), /changed/);129+ assert.equal(changed.sent.length, 0);130+ const refused = setup("WikiCompose", page, {title: "T", name: "t", content: "x"}, {submitter: "propose", results: [{accepted: false, message: "blocked"}]});131+ await assert.rejects(refused.component.submit(refused.form), /blocked/);132+ assert.equal(refused.sent.length, 1);133+ assert.deepEqual(refused.tiny.navigated, []);134+});135+136+test("nostr-react signs the reaction the pressed button carries", async () => {137+ for (const [reaction, message] of [["+", "Accepted."], ["-", "Rejected."]]) {138+ const s = setup("NostrReact", {event: mergeID, pubkey: author, kind: "818"}, {}, {submitter: reaction, label: reaction === "+" ? "Accept" : "Reject"});139+ await s.component.submit(s.form);140+ const event = s.sent[0].event;141+ assert.equal(event.kind, 7);142+ assert.equal(event.content, reaction);143+ assert.deepEqual(event.tags, [["e", mergeID, "", author], ["p", author], ["k", "818"]]);144+ assert.equal(verifyEvent(event), true);145+ assert.equal(s.component.message, message);146+ }147+ const plain = setup("NostrReact", {event: mergeID, pubkey: author}, {}, {submitter: "+"});148+ await plain.component.submit(plain.form);149+ assert.deepEqual(plain.sent[0].event.tags, [["e", mergeID, "", author], ["p", author]]);150+});151+152+test("nostr-react refuses unknown reactions and bad addresses before signing", async () => {153+ for (const [attributes, submitter, message] of [154+ [{event: mergeID, pubkey: author}, "meh", /Approve or Deny/],155+ [{event: "short", pubkey: author}, "+", /missing/],156+ [{event: mergeID, pubkey: ""}, "-", /missing/]157+ ]) {158+ const s = setup("NostrReact", attributes, {}, {submitter});159+ await assert.rejects(s.component.submit(s.form), message);160+ assert.equal(s.signed.length, 0);161+ assert.equal(s.sent.length, 0);162+ }163+});