clone

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

offer approve, deny and reply on the notification and list approvals through the browser tools

Dami committed ee5e682
commit
ee5e682bb2396d12cffed2acbf79cb903c4025e4
parent
361beb5a8ad9517ad535e21f550299b1a0f4f4ec
author
Dami <dami.etoile@gmail.com>
6 files changed, +222 -6
Minternal/webui/sw.js11+-
Minternal/webui/webmcp.go2+-
Minternal/webui/webmcp.js9+-
Mpackage.json3+-
Ascripts/approvals.test.mjs174++++++++++++++++++++++++++++++
Mscripts/webmcp.test.mjs29+++++
M internal/webui/sw.js +8 -3
@@ -67,19 +67,24 @@ self.addEventListener("backgroundfetchclick", event => {
6767 });6868 6969 // Notifications are opt-in per device; a push only arrives after the person70-// enabled them on this browser. The payload is the relay's short summary.70+// enabled them on this browser. The payload is the relay's short summary,71+// with the answers a request for a decision offers as notification actions.72+const notificationActions = list => Array.isArray(list) ? list.filter(item => item && /^[a-z]+$/.test(item.action || "")).map(item => ({action: item.action, title: String(item.title || item.action)})) : [];7173 self.addEventListener("push", event => {7274 let data = {};7375 try { data = event.data?.json() || {}; } catch { data = {body: event.data?.text() || ""}; }7476 const base = new URL(self.registration.scope);7577 const badge = Number.isInteger(data.badge) && data.badge > 0 ? navigator.setAppBadge?.(data.badge) : navigator.clearAppBadge?.();7678 event.waitUntil(Promise.all([Promise.resolve(badge).catch(() => {}), self.registration.showNotification(data.title || "tiny", {77- body: data.body || "", tag: data.tag || "tiny", icon: new URL("icon-192.png", base).href, badge: new URL("badge-96.png", base).href, data: {url: data.url || base.href}79+ body: data.body || "", tag: data.tag || "tiny", icon: new URL("icon-192.png", base).href, badge: new URL("badge-96.png", base).href, data: {url: data.url || base.href}, actions: notificationActions(data.actions)7880 })]));7981 });82+// An action opens the notification's page with answer=<action>, where the83+// page asks for confirmation before anything is signed.84+const answerURL = (url, action) => /^[a-z]+$/.test(action || "") ? url + (url.includes("?") ? "&" : "?") + "answer=" + action : url;8085 self.addEventListener("notificationclick", event => {8186 event.notification.close();82- const target = event.notification.data?.url || self.registration.scope;87+ const target = answerURL(event.notification.data?.url || self.registration.scope, event.action);8388 event.waitUntil(self.clients.matchAll({type: "window", includeUncontrolled: true}).then(async list => {8489 const open = list.find(client => client.url.startsWith(self.registration.scope));8590 if (open) { const focused = await open.navigate(target); return focused?.focus(); }
M internal/webui/webmcp.go +1 -1
M internal/webui/webmcp.js +8 -1
@@ -99,6 +99,11 @@
9999 object({hash}, ["hash"]), reads, (input, signal) => query("browsefile", input, signal));100100 register("tiny.read_status", "Read service health, storage and job status. Requires a signed-in owner or moderator session.",101101 object(), reads, (input, signal) => query("browsestatus", input, signal));102+ register("tiny.list_approvals", "List requests for a decision addressed to the signed-in person, with each request's asker, subject, expiry, state and answer, plus counts of open, answered and expired requests. Answering is the person's own signed action.",103+ object({cursor: text, limit, state: {type: "string", enum: ["open", "answered", "expired", "all"]}}), reads,104+ (input, signal) => query("browseapprovals", input, signal));105+ register("tiny.read_approval", "Read one request for a decision by event id with every reaction and reply from the people asked.",106+ object({id: hash}, ["id"]), reads, (input, signal) => query("browseapproval", input, signal));102107 103108 const readMethods = ["stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"];104109 register("tiny.read_management", "Read relay configuration, jobs, backups, delivery status or members using your connected signer. gitstorage requires owner and repo.",
@@ -135,6 +140,8 @@
135140 object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash)));136141 register("tiny.open_status", "Open the relay health page in this tab: build versions, service status and browser tool readiness.", object(), {}, () => open("/manage/health"));137142 register("tiny.open_files", "Open the Files page in this tab, where files and folders upload and shared items wait.", object(), {}, () => open("/files"));143+ register("tiny.open_approvals", "Open the Approvals page in this tab, where requests for a decision wait. Pass id to focus one request; the person answers with a signed tap.",144+ object({id: hash}), {}, input => open("/approvals" + (input.id ? "?id=" + encodeURIComponent(input.id) : "")));138145 register("tiny.open_link", "Open a nostr link in this tab: an npub, nprofile, note, nevent, naddr or 64-character event id, with or without a nostr: or web+nostr: prefix.",139146 object({target: {type: "string", minLength: 1, maxLength: 512}}, ["target"]), {}, input => open("/open?target=" + encodeURIComponent(input.target)));140147 register("tiny.read_notifications", "Read whether this device receives relay notifications, which categories it chose and the browser permission state.",
@@ -144,7 +151,7 @@
144151 supported: "PushManager" in window && "Notification" in window,145152 permission: typeof Notification === "function" ? Notification.permission : "unsupported",146153 enabled: Boolean(localStorage.getItem("tiny.push")),147- categories: categories.length ? categories : ["messages", "replies", "mentions", "relay"],154+ categories: categories.length ? categories : ["messages", "replies", "mentions", "approvals", "relay"],148155 note: "Enabling notifications needs a person to press Enable on this device under Inbox."149156 };150157 });
M package.json +2 -1
A scripts/approvals.test.mjs +174 -0
@@ -0,0 +1,174 @@
1+// Approvals in the browser: the nostr-react element signs the answer the2+// person chose, and the service worker routes a notification action to the3+// page that asks for confirmation. Both run in a sandbox with a fake DOM.4+import test from "node:test";5+import assert from "node:assert/strict";6+import fs from "node:fs";7+import vm from "node:vm";8+import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools";9+10+const request = "a".repeat(64), asker = "b".repeat(64);11+const source = fs.readFileSync("internal/webui/components.js", "utf8");12+const reactSource = source.slice(source.indexOf(" const publishSigned = "), source.indexOf(" // ApprovalItem reacts"));13+14+// node builds enough of an element for the confirmation prompt: text,15+// listeners, children and focus.16+const node = (name, text) => ({17+ name, textContent: text ?? "", children: [], handlers: {}, disabled: false, focused: false, dataset: {},18+ addEventListener(event, fn) { this.handlers[event] = fn; },19+ replaceChildren(...items) { this.children = items; },20+ focus() { this.focused = true; },21+ click() { return this.handlers.click?.(); }22+});23+24+function setup(attributes, options = {}) {25+ const sent = [], signed = [], listeners = {};26+ let navigated = null;27+ class FormElement {28+ constructor() { this.form = {buttons: []}; this.output = node("output"); this.reports = []; }29+ getAttribute(name) { return attributes[name] ?? null; }30+ report(message, error) { this.reports.push(message); this.output.textContent = message; if (error) this.output.dataset.error = ""; }31+ busy() {}32+ }33+ const window = {NostrSigner: {verifyEvent}, nostr: options.noSigner ? undefined : {signEvent: async event => {34+ signed.push(structuredClone(event));35+ if (options.change) event.content = "changed";36+ return finalizeEvent(structuredClone(event), generateSecretKey());37+ }}};38+ const tiny = {39+ signedFetch: async (path, method, body) => { sent.push({path, method, event: JSON.parse(body)}); return Response.json(options.result ?? {accepted: true}); },40+ navigate: async href => { navigated = href; }41+ };42+ const document = {addEventListener(event, fn) { listeners[event] = fn; }};43+ const Constructor = vm.runInNewContext(`${reactSource}\nNostrReact`, {FormElement, window, tiny, document, el: node, isHex64: value => /^[0-9a-f]{64}$/.test(value || ""), globalThis: {location: {href: "https://tiny.example/approvals?id=" + request + "&answer=approve"}}, Date, JSON, Math, Object, Response});44+ const component = new Constructor();45+ return {component, sent, signed, listeners, navigated: () => navigated};46+}47+const attrs = {event: request, pubkey: asker, kind: "1111"};48+49+test("approve and deny sign a kind 7 reaction naming the request and its asker", async () => {50+ for (const [choice, word] of [["+", "Approved."], ["-", "Denied."]]) {51+ const s = setup(attrs);52+ await s.component.submit(s.component.form, choice);53+ assert.equal(s.sent.length, 1);54+ assert.equal(s.sent[0].path, "/events");55+ assert.equal(s.sent[0].method, "POST");56+ const event = s.sent[0].event;57+ assert.equal(event.kind, 7);58+ assert.equal(event.content, choice);59+ assert.deepEqual(event.tags, [["e", request, "", asker], ["p", asker], ["k", "1111"]]);60+ assert.equal(verifyEvent(event), true);61+ assert.equal(s.component.reports.at(-1), word);62+ assert.equal(s.navigated(), "https://tiny.example/approvals");63+ }64+});65+66+test("the pressed button chooses the answer and nothing is signed without a choice", async () => {67+ const s = setup(attrs);68+ await assert.rejects(s.component.submit(s.component.form), /Choose Approve or Deny/);69+ s.component.choice = "-";70+ await s.component.submit(s.component.form);71+ assert.equal(s.sent[0].event.content, "-");72+ assert.equal(s.component.choice, null);73+});74+75+test("a missing address, a changed event or a relay refusal signs or publishes nothing", async () => {76+ const missing = setup({event: request});77+ await assert.rejects(missing.component.submit(missing.component.form, "+"), /request address/);78+ assert.equal(missing.signed.length, 0);79+ const changed = setup(attrs, {change: true});80+ await assert.rejects(changed.component.submit(changed.component.form, "+"), /changed/);81+ assert.equal(changed.sent.length, 0);82+ const refused = setup(attrs, {result: {accepted: false, message: "blocked"}});83+ await assert.rejects(refused.component.submit(refused.component.form, "+"), /blocked/);84+ assert.equal(refused.navigated(), null);85+ const unsigned = setup(attrs, {noSigner: true});86+ await assert.rejects(unsigned.component.submit(unsigned.component.form, "+"), /Connect a signer/);87+});88+89+test("the confirmation prompt signs only after Confirm and waits for a signer", async () => {90+ const s = setup(attrs);91+ s.component.prompt("approve");92+ assert.equal(s.signed.length, 0);93+ const [question, confirm, , cancel] = s.component.output.children;94+ assert.equal(question.textContent, "Approve this request? ");95+ assert.equal(confirm.textContent, "Confirm");96+ assert.equal(confirm.focused, true);97+ cancel.click();98+ assert.equal(s.component.output.textContent, "");99+ s.component.prompt("deny");100+ await s.component.output.children[1].click();101+ await new Promise(resolve => setTimeout(resolve, 20));102+ assert.equal(s.sent.length, 1);103+ assert.equal(s.sent[0].event.content, "-");104+ const unknown = setup(attrs);105+ unknown.component.prompt("reply");106+ assert.equal(unknown.component.output.children.length, 0);107+ const waiting = setup(attrs, {noSigner: true});108+ waiting.component.prompt("approve");109+ assert.match(waiting.component.output.textContent, /Connect a signer/);110+ assert.equal(typeof waiting.listeners["tiny:signer"], "function");111+});112+113+// The service worker runs against a fake self with a registration and a114+// client list, so the click handler's window choice is observable.115+const workerSource = fs.readFileSync("internal/webui/sw.js", "utf8");116+function worker({windows = []} = {}) {117+ const handlers = {}, shown = [], opened = [];118+ const self = {119+ addEventListener(name, fn) { handlers[name] = fn; },120+ skipWaiting() {},121+ registration: {scope: "https://tiny.example/", showNotification: async (title, options) => { shown.push({title, options}); }},122+ clients: {claim() {}, matchAll: async () => windows, openWindow: async url => { opened.push(url); return {focus() {}}; }}123+ };124+ vm.runInNewContext(workerSource, {self, navigator: {}, location: new URL("https://tiny.example/"), URL, caches: {keys: async () => []}, console});125+ return {handlers, shown, opened};126+}127+const waitUntil = () => { let done; const event = {waitUntil(promise) { done = promise; }}; return {event, finished: () => done}; };128+129+test("push renders the relay's actions on the notification", async () => {130+ const w = worker();131+ const {event, finished} = waitUntil();132+ event.data = {json: () => ({title: "tiny", body: "hermes asks: Publish?", url: "https://tiny.example/approvals?id=" + request, tag: "tiny-approvals", badge: 1, actions: [{action: "approve", title: "Approve"}, {action: "deny", title: "Deny"}, {action: "reply", title: "Reply"}, {action: "bad action"}]})};133+ w.handlers.push(event);134+ await finished();135+ assert.equal(w.shown.length, 1);136+ assert.equal(JSON.stringify(w.shown[0].options.actions), JSON.stringify([{action: "approve", title: "Approve"}, {action: "deny", title: "Deny"}, {action: "reply", title: "Reply"}]));137+ assert.equal(w.shown[0].options.data.url, "https://tiny.example/approvals?id=" + request);138+ const plain = worker();139+ const second = waitUntil();140+ second.event.data = {json: () => ({title: "tiny", body: "Replied to you"})};141+ plain.handlers.push(second.event);142+ await second.finished();143+ assert.equal(plain.shown[0].options.actions.length, 0);144+});145+146+test("a notification action opens the answer in the existing window, or a new one", async () => {147+ const url = "https://tiny.example/approvals?id=" + request;148+ const navigated = [];149+ const open = {url: "https://tiny.example/inbox", navigate: async target => { navigated.push(target); return {focus() { navigated.push("focused"); }}; }};150+ const existing = worker({windows: [open]});151+ for (const [action, expected] of [["approve", url + "&answer=approve"], ["deny", url + "&answer=deny"], ["reply", url + "&answer=reply"], ["", url], [undefined, url]]) {152+ const {event, finished} = waitUntil();153+ event.action = action;154+ event.notification = {close() {}, data: {url}};155+ existing.handlers.notificationclick(event);156+ await finished();157+ assert.equal(navigated.at(-2), expected);158+ assert.equal(navigated.at(-1), "focused");159+ }160+ assert.equal(existing.opened.length, 0);161+ const fresh = worker();162+ const {event, finished} = waitUntil();163+ event.action = "approve";164+ event.notification = {close() {}, data: {url: "https://tiny.example/approvals"}};165+ fresh.handlers.notificationclick(event);166+ await finished();167+ assert.deepEqual(fresh.opened, ["https://tiny.example/approvals?answer=approve"]);168+ const odd = waitUntil();169+ odd.event.action = "../x";170+ odd.event.notification = {close() {}, data: {url}};171+ fresh.handlers.notificationclick(odd.event);172+ await odd.finished();173+ assert.equal(fresh.opened.at(-1), url);174+});
M scripts/webmcp.test.mjs +29 -0
@@ -131,6 +131,35 @@ test("collaboration tools preserve repository, event and filter scope", async ()
131131 }132132 });133133 134+test("approval tools list, read and open requests for a decision", async () => {135+ const requests = [];136+ const {tools, sandbox} = await browser({path: "/r/work/approvals", fetch: async url => {137+ requests.push(new URL(url, "https://tiny.example"));138+ return new Response(JSON.stringify({items: [{id: "a".repeat(64), state: "open"}], counts: {open: 1}}));139+ }});140+ const list = tools.get("tiny.list_approvals");141+ assert.equal(list.annotations.readOnlyHint, true);142+ const listed = await list.execute({state: "open", limit: 5});143+ assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");144+ assert.equal(requests.at(-1).searchParams.get("method"), "browseapprovals");145+ assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", limit: 5}]);146+ assert.equal(listed.structuredContent.result.counts.open, 1);147+ assert.equal(list.inputSchema.properties.state.enum.join(","), "open,answered,expired,all");148+ const read = tools.get("tiny.read_approval");149+ assert.equal(read.annotations.readOnlyHint, true);150+ assert.equal(read.inputSchema.required.join(","), "id");151+ await read.execute({id: "b".repeat(64)});152+ assert.equal(requests.at(-1).searchParams.get("method"), "browseapproval");153+ assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);154+ await tools.get("tiny.open_approvals").execute({});155+ assert.equal(new URL(sandbox.opened).pathname, "/r/work/approvals");156+ await tools.get("tiny.open_approvals").execute({id: "c".repeat(64)});157+ const opened = new URL(sandbox.opened);158+ assert.equal(opened.pathname, "/r/work/approvals");159+ assert.equal(opened.searchParams.get("id"), "c".repeat(64));160+ assert.equal(opened.searchParams.get("answer"), null);161+});162+134163 test("link, files and notification tools reach the new surfaces", async () => {135164 const calls = [];136165 const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (url, method, body) => {