- commit
- ee5e682bb2396d12cffed2acbf79cb903c4025e4
- parent
- 361beb5a8ad9517ad535e21f550299b1a0f4f4ec
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 11 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 9 | +- |
| M | package.json | 3 | +- |
| A | scripts/ | 174 | ++++++++++++++++++++++++++++++ |
| M | scripts/ | 29 | +++++ |
@@ -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(); }@@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) {
2626 return2727 }2828 }29- allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true}29+ allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true, "browseapprovals": true, "browseapproval": true}3030 if !allowed[method] {3131 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"})3232 return@@ -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 });@@ -9,7 +9,8 @@
99 "test:signer": "node --test scripts/signer.test.mjs",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",12- "test:agents": "node --test scripts/agent-grant.test.mjs"12+ "test:agents": "node --test scripts/agent-grant.test.mjs",13+ "test:approvals": "node --test scripts/approvals.test.mjs"1314 },1415 "devDependencies": {1516 "@noble/hashes": "2.4.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+});@@ -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) => {