| 1 | diff --git a/internal/webui/sw.js b/internal/webui/sw.js |
| 2 | index 14e8f2b..f0cb226 100644 |
| 3 | --- a/internal/webui/sw.js |
| 4 | +++ b/internal/webui/sw.js |
| 5 | @@ -67,19 +67,24 @@ self.addEventListener("backgroundfetchclick", event => { |
| 6 | }); |
| 7 | |
| 8 | // Notifications are opt-in per device; a push only arrives after the person |
| 9 | -// enabled them on this browser. The payload is the relay's short summary. |
| 10 | +// enabled them on this browser. The payload is the relay's short summary, |
| 11 | +// with the answers a request for a decision offers as notification actions. |
| 12 | +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)})) : []; |
| 13 | self.addEventListener("push", event => { |
| 14 | let data = {}; |
| 15 | try { data = event.data?.json() || {}; } catch { data = {body: event.data?.text() || ""}; } |
| 16 | const base = new URL(self.registration.scope); |
| 17 | const badge = Number.isInteger(data.badge) && data.badge > 0 ? navigator.setAppBadge?.(data.badge) : navigator.clearAppBadge?.(); |
| 18 | event.waitUntil(Promise.all([Promise.resolve(badge).catch(() => {}), self.registration.showNotification(data.title || "tiny", { |
| 19 | - 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} |
| 20 | + 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) |
| 21 | })])); |
| 22 | }); |
| 23 | +// An action opens the notification's page with answer=<action>, where the |
| 24 | +// page asks for confirmation before anything is signed. |
| 25 | +const answerURL = (url, action) => /^[a-z]+$/.test(action || "") ? url + (url.includes("?") ? "&" : "?") + "answer=" + action : url; |
| 26 | self.addEventListener("notificationclick", event => { |
| 27 | event.notification.close(); |
| 28 | - const target = event.notification.data?.url || self.registration.scope; |
| 29 | + const target = answerURL(event.notification.data?.url || self.registration.scope, event.action); |
| 30 | event.waitUntil(self.clients.matchAll({type: "window", includeUncontrolled: true}).then(async list => { |
| 31 | const open = list.find(client => client.url.startsWith(self.registration.scope)); |
| 32 | if (open) { const focused = await open.navigate(target); return focused?.focus(); } |
| 33 | diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go |
| 34 | index 84a48b5..93262cb 100644 |
| 35 | --- a/internal/webui/webmcp.go |
| 36 | +++ b/internal/webui/webmcp.go |
| 37 | @@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) { |
| 38 | return |
| 39 | } |
| 40 | } |
| 41 | - 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} |
| 42 | + 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} |
| 43 | if !allowed[method] { |
| 44 | writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"}) |
| 45 | return |
| 46 | diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js |
| 47 | index 3405416..97e1d7a 100644 |
| 48 | --- a/internal/webui/webmcp.js |
| 49 | +++ b/internal/webui/webmcp.js |
| 50 | @@ -99,6 +99,11 @@ |
| 51 | object({hash}, ["hash"]), reads, (input, signal) => query("browsefile", input, signal)); |
| 52 | register("tiny.read_status", "Read service health, storage and job status. Requires a signed-in owner or moderator session.", |
| 53 | object(), reads, (input, signal) => query("browsestatus", input, signal)); |
| 54 | + 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.", |
| 55 | + object({cursor: text, limit, state: {type: "string", enum: ["open", "answered", "expired", "all"]}}), reads, |
| 56 | + (input, signal) => query("browseapprovals", input, signal)); |
| 57 | + register("tiny.read_approval", "Read one request for a decision by event id with every reaction and reply from the people asked.", |
| 58 | + object({id: hash}, ["id"]), reads, (input, signal) => query("browseapproval", input, signal)); |
| 59 | |
| 60 | const readMethods = ["stats", "getpolicy", "listaudit", "listjobs", "listbackups", "listdumps", "deliverystatus", "storagestats", "gitstorage", "listconnections", "listmembers"]; |
| 61 | register("tiny.read_management", "Read relay configuration, jobs, backups, delivery status or members using your connected signer. gitstorage requires owner and repo.", |
| 62 | @@ -135,6 +140,8 @@ |
| 63 | object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash))); |
| 64 | register("tiny.open_status", "Open the relay health page in this tab: build versions, service status and browser tool readiness.", object(), {}, () => open("/manage/health")); |
| 65 | register("tiny.open_files", "Open the Files page in this tab, where files and folders upload and shared items wait.", object(), {}, () => open("/files")); |
| 66 | + 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.", |
| 67 | + object({id: hash}), {}, input => open("/approvals" + (input.id ? "?id=" + encodeURIComponent(input.id) : ""))); |
| 68 | 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.", |
| 69 | object({target: {type: "string", minLength: 1, maxLength: 512}}, ["target"]), {}, input => open("/open?target=" + encodeURIComponent(input.target))); |
| 70 | register("tiny.read_notifications", "Read whether this device receives relay notifications, which categories it chose and the browser permission state.", |
| 71 | @@ -144,7 +151,7 @@ |
| 72 | supported: "PushManager" in window && "Notification" in window, |
| 73 | permission: typeof Notification === "function" ? Notification.permission : "unsupported", |
| 74 | enabled: Boolean(localStorage.getItem("tiny.push")), |
| 75 | - categories: categories.length ? categories : ["messages", "replies", "mentions", "relay"], |
| 76 | + categories: categories.length ? categories : ["messages", "replies", "mentions", "approvals", "relay"], |
| 77 | note: "Enabling notifications needs a person to press Enable on this device under Inbox." |
| 78 | }; |
| 79 | }); |
| 80 | diff --git a/package.json b/package.json |
| 81 | index 1548abc..db1d6a0 100644 |
| 82 | --- a/package.json |
| 83 | +++ b/package.json |
| 84 | @@ -9,7 +9,8 @@ |
| 85 | "test:signer": "node --test scripts/signer.test.mjs", |
| 86 | "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", |
| 87 | "test:collaboration": "node --test scripts/collaboration-compose.test.mjs", |
| 88 | - "test:agents": "node --test scripts/agent-grant.test.mjs" |
| 89 | + "test:agents": "node --test scripts/agent-grant.test.mjs", |
| 90 | + "test:approvals": "node --test scripts/approvals.test.mjs" |
| 91 | }, |
| 92 | "devDependencies": { |
| 93 | "@noble/hashes": "2.4.0", |
| 94 | diff --git a/scripts/approvals.test.mjs b/scripts/approvals.test.mjs |
| 95 | new file mode 100644 |
| 96 | index 0000000..525b750 |
| 97 | --- /dev/null |
| 98 | +++ b/scripts/approvals.test.mjs |
| 99 | @@ -0,0 +1,174 @@ |
| 100 | +// Approvals in the browser: the nostr-react element signs the answer the |
| 101 | +// person chose, and the service worker routes a notification action to the |
| 102 | +// page that asks for confirmation. Both run in a sandbox with a fake DOM. |
| 103 | +import test from "node:test"; |
| 104 | +import assert from "node:assert/strict"; |
| 105 | +import fs from "node:fs"; |
| 106 | +import vm from "node:vm"; |
| 107 | +import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools"; |
| 108 | + |
| 109 | +const request = "a".repeat(64), asker = "b".repeat(64); |
| 110 | +const source = fs.readFileSync("internal/webui/components.js", "utf8"); |
| 111 | +const reactSource = source.slice(source.indexOf(" const publishSigned = "), source.indexOf(" // ApprovalItem reacts")); |
| 112 | + |
| 113 | +// node builds enough of an element for the confirmation prompt: text, |
| 114 | +// listeners, children and focus. |
| 115 | +const node = (name, text) => ({ |
| 116 | + name, textContent: text ?? "", children: [], handlers: {}, disabled: false, focused: false, dataset: {}, |
| 117 | + addEventListener(event, fn) { this.handlers[event] = fn; }, |
| 118 | + replaceChildren(...items) { this.children = items; }, |
| 119 | + focus() { this.focused = true; }, |
| 120 | + click() { return this.handlers.click?.(); } |
| 121 | +}); |
| 122 | + |
| 123 | +function setup(attributes, options = {}) { |
| 124 | + const sent = [], signed = [], listeners = {}; |
| 125 | + let navigated = null; |
| 126 | + class FormElement { |
| 127 | + constructor() { this.form = {buttons: []}; this.output = node("output"); this.reports = []; } |
| 128 | + getAttribute(name) { return attributes[name] ?? null; } |
| 129 | + report(message, error) { this.reports.push(message); this.output.textContent = message; if (error) this.output.dataset.error = ""; } |
| 130 | + busy() {} |
| 131 | + } |
| 132 | + const window = {NostrSigner: {verifyEvent}, nostr: options.noSigner ? undefined : {signEvent: async event => { |
| 133 | + signed.push(structuredClone(event)); |
| 134 | + if (options.change) event.content = "changed"; |
| 135 | + return finalizeEvent(structuredClone(event), generateSecretKey()); |
| 136 | + }}}; |
| 137 | + const tiny = { |
| 138 | + signedFetch: async (path, method, body) => { sent.push({path, method, event: JSON.parse(body)}); return Response.json(options.result ?? {accepted: true}); }, |
| 139 | + navigate: async href => { navigated = href; } |
| 140 | + }; |
| 141 | + const document = {addEventListener(event, fn) { listeners[event] = fn; }}; |
| 142 | + 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}); |
| 143 | + const component = new Constructor(); |
| 144 | + return {component, sent, signed, listeners, navigated: () => navigated}; |
| 145 | +} |
| 146 | +const attrs = {event: request, pubkey: asker, kind: "1111"}; |
| 147 | + |
| 148 | +test("approve and deny sign a kind 7 reaction naming the request and its asker", async () => { |
| 149 | + for (const [choice, word] of [["+", "Approved."], ["-", "Denied."]]) { |
| 150 | + const s = setup(attrs); |
| 151 | + await s.component.submit(s.component.form, choice); |
| 152 | + assert.equal(s.sent.length, 1); |
| 153 | + assert.equal(s.sent[0].path, "/events"); |
| 154 | + assert.equal(s.sent[0].method, "POST"); |
| 155 | + const event = s.sent[0].event; |
| 156 | + assert.equal(event.kind, 7); |
| 157 | + assert.equal(event.content, choice); |
| 158 | + assert.deepEqual(event.tags, [["e", request, "", asker], ["p", asker], ["k", "1111"]]); |
| 159 | + assert.equal(verifyEvent(event), true); |
| 160 | + assert.equal(s.component.reports.at(-1), word); |
| 161 | + assert.equal(s.navigated(), "https://tiny.example/approvals"); |
| 162 | + } |
| 163 | +}); |
| 164 | + |
| 165 | +test("the pressed button chooses the answer and nothing is signed without a choice", async () => { |
| 166 | + const s = setup(attrs); |
| 167 | + await assert.rejects(s.component.submit(s.component.form), /Choose Approve or Deny/); |
| 168 | + s.component.choice = "-"; |
| 169 | + await s.component.submit(s.component.form); |
| 170 | + assert.equal(s.sent[0].event.content, "-"); |
| 171 | + assert.equal(s.component.choice, null); |
| 172 | +}); |
| 173 | + |
| 174 | +test("a missing address, a changed event or a relay refusal signs or publishes nothing", async () => { |
| 175 | + const missing = setup({event: request}); |
| 176 | + await assert.rejects(missing.component.submit(missing.component.form, "+"), /request address/); |
| 177 | + assert.equal(missing.signed.length, 0); |
| 178 | + const changed = setup(attrs, {change: true}); |
| 179 | + await assert.rejects(changed.component.submit(changed.component.form, "+"), /changed/); |
| 180 | + assert.equal(changed.sent.length, 0); |
| 181 | + const refused = setup(attrs, {result: {accepted: false, message: "blocked"}}); |
| 182 | + await assert.rejects(refused.component.submit(refused.component.form, "+"), /blocked/); |
| 183 | + assert.equal(refused.navigated(), null); |
| 184 | + const unsigned = setup(attrs, {noSigner: true}); |
| 185 | + await assert.rejects(unsigned.component.submit(unsigned.component.form, "+"), /Connect a signer/); |
| 186 | +}); |
| 187 | + |
| 188 | +test("the confirmation prompt signs only after Confirm and waits for a signer", async () => { |
| 189 | + const s = setup(attrs); |
| 190 | + s.component.prompt("approve"); |
| 191 | + assert.equal(s.signed.length, 0); |
| 192 | + const [question, confirm, , cancel] = s.component.output.children; |
| 193 | + assert.equal(question.textContent, "Approve this request? "); |
| 194 | + assert.equal(confirm.textContent, "Confirm"); |
| 195 | + assert.equal(confirm.focused, true); |
| 196 | + cancel.click(); |
| 197 | + assert.equal(s.component.output.textContent, ""); |
| 198 | + s.component.prompt("deny"); |
| 199 | + await s.component.output.children[1].click(); |
| 200 | + await new Promise(resolve => setTimeout(resolve, 20)); |
| 201 | + assert.equal(s.sent.length, 1); |
| 202 | + assert.equal(s.sent[0].event.content, "-"); |
| 203 | + const unknown = setup(attrs); |
| 204 | + unknown.component.prompt("reply"); |
| 205 | + assert.equal(unknown.component.output.children.length, 0); |
| 206 | + const waiting = setup(attrs, {noSigner: true}); |
| 207 | + waiting.component.prompt("approve"); |
| 208 | + assert.match(waiting.component.output.textContent, /Connect a signer/); |
| 209 | + assert.equal(typeof waiting.listeners["tiny:signer"], "function"); |
| 210 | +}); |
| 211 | + |
| 212 | +// The service worker runs against a fake self with a registration and a |
| 213 | +// client list, so the click handler's window choice is observable. |
| 214 | +const workerSource = fs.readFileSync("internal/webui/sw.js", "utf8"); |
| 215 | +function worker({windows = []} = {}) { |
| 216 | + const handlers = {}, shown = [], opened = []; |
| 217 | + const self = { |
| 218 | + addEventListener(name, fn) { handlers[name] = fn; }, |
| 219 | + skipWaiting() {}, |
| 220 | + registration: {scope: "https://tiny.example/", showNotification: async (title, options) => { shown.push({title, options}); }}, |
| 221 | + clients: {claim() {}, matchAll: async () => windows, openWindow: async url => { opened.push(url); return {focus() {}}; }} |
| 222 | + }; |
| 223 | + vm.runInNewContext(workerSource, {self, navigator: {}, location: new URL("https://tiny.example/"), URL, caches: {keys: async () => []}, console}); |
| 224 | + return {handlers, shown, opened}; |
| 225 | +} |
| 226 | +const waitUntil = () => { let done; const event = {waitUntil(promise) { done = promise; }}; return {event, finished: () => done}; }; |
| 227 | + |
| 228 | +test("push renders the relay's actions on the notification", async () => { |
| 229 | + const w = worker(); |
| 230 | + const {event, finished} = waitUntil(); |
| 231 | + 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"}]})}; |
| 232 | + w.handlers.push(event); |
| 233 | + await finished(); |
| 234 | + assert.equal(w.shown.length, 1); |
| 235 | + assert.equal(JSON.stringify(w.shown[0].options.actions), JSON.stringify([{action: "approve", title: "Approve"}, {action: "deny", title: "Deny"}, {action: "reply", title: "Reply"}])); |
| 236 | + assert.equal(w.shown[0].options.data.url, "https://tiny.example/approvals?id=" + request); |
| 237 | + const plain = worker(); |
| 238 | + const second = waitUntil(); |
| 239 | + second.event.data = {json: () => ({title: "tiny", body: "Replied to you"})}; |
| 240 | + plain.handlers.push(second.event); |
| 241 | + await second.finished(); |
| 242 | + assert.equal(plain.shown[0].options.actions.length, 0); |
| 243 | +}); |
| 244 | + |
| 245 | +test("a notification action opens the answer in the existing window, or a new one", async () => { |
| 246 | + const url = "https://tiny.example/approvals?id=" + request; |
| 247 | + const navigated = []; |
| 248 | + const open = {url: "https://tiny.example/inbox", navigate: async target => { navigated.push(target); return {focus() { navigated.push("focused"); }}; }}; |
| 249 | + const existing = worker({windows: [open]}); |
| 250 | + for (const [action, expected] of [["approve", url + "&answer=approve"], ["deny", url + "&answer=deny"], ["reply", url + "&answer=reply"], ["", url], [undefined, url]]) { |
| 251 | + const {event, finished} = waitUntil(); |
| 252 | + event.action = action; |
| 253 | + event.notification = {close() {}, data: {url}}; |
| 254 | + existing.handlers.notificationclick(event); |
| 255 | + await finished(); |
| 256 | + assert.equal(navigated.at(-2), expected); |
| 257 | + assert.equal(navigated.at(-1), "focused"); |
| 258 | + } |
| 259 | + assert.equal(existing.opened.length, 0); |
| 260 | + const fresh = worker(); |
| 261 | + const {event, finished} = waitUntil(); |
| 262 | + event.action = "approve"; |
| 263 | + event.notification = {close() {}, data: {url: "https://tiny.example/approvals"}}; |
| 264 | + fresh.handlers.notificationclick(event); |
| 265 | + await finished(); |
| 266 | + assert.deepEqual(fresh.opened, ["https://tiny.example/approvals?answer=approve"]); |
| 267 | + const odd = waitUntil(); |
| 268 | + odd.event.action = "../x"; |
| 269 | + odd.event.notification = {close() {}, data: {url}}; |
| 270 | + fresh.handlers.notificationclick(odd.event); |
| 271 | + await odd.finished(); |
| 272 | + assert.equal(fresh.opened.at(-1), url); |
| 273 | +}); |
| 274 | diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs |
| 275 | index f18c63c..b4f0abf 100644 |
| 276 | --- a/scripts/webmcp.test.mjs |
| 277 | +++ b/scripts/webmcp.test.mjs |
| 278 | @@ -131,6 +131,35 @@ test("collaboration tools preserve repository, event and filter scope", async () |
| 279 | } |
| 280 | }); |
| 281 | |
| 282 | +test("approval tools list, read and open requests for a decision", async () => { |
| 283 | + const requests = []; |
| 284 | + const {tools, sandbox} = await browser({path: "/r/work/approvals", fetch: async url => { |
| 285 | + requests.push(new URL(url, "https://tiny.example")); |
| 286 | + return new Response(JSON.stringify({items: [{id: "a".repeat(64), state: "open"}], counts: {open: 1}})); |
| 287 | + }}); |
| 288 | + const list = tools.get("tiny.list_approvals"); |
| 289 | + assert.equal(list.annotations.readOnlyHint, true); |
| 290 | + const listed = await list.execute({state: "open", limit: 5}); |
| 291 | + assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query"); |
| 292 | + assert.equal(requests.at(-1).searchParams.get("method"), "browseapprovals"); |
| 293 | + assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", limit: 5}]); |
| 294 | + assert.equal(listed.structuredContent.result.counts.open, 1); |
| 295 | + assert.equal(list.inputSchema.properties.state.enum.join(","), "open,answered,expired,all"); |
| 296 | + const read = tools.get("tiny.read_approval"); |
| 297 | + assert.equal(read.annotations.readOnlyHint, true); |
| 298 | + assert.equal(read.inputSchema.required.join(","), "id"); |
| 299 | + await read.execute({id: "b".repeat(64)}); |
| 300 | + assert.equal(requests.at(-1).searchParams.get("method"), "browseapproval"); |
| 301 | + assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]); |
| 302 | + await tools.get("tiny.open_approvals").execute({}); |
| 303 | + assert.equal(new URL(sandbox.opened).pathname, "/r/work/approvals"); |
| 304 | + await tools.get("tiny.open_approvals").execute({id: "c".repeat(64)}); |
| 305 | + const opened = new URL(sandbox.opened); |
| 306 | + assert.equal(opened.pathname, "/r/work/approvals"); |
| 307 | + assert.equal(opened.searchParams.get("id"), "c".repeat(64)); |
| 308 | + assert.equal(opened.searchParams.get("answer"), null); |
| 309 | +}); |
| 310 | + |
| 311 | test("link, files and notification tools reach the new surfaces", async () => { |
| 312 | const calls = []; |
| 313 | const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (url, method, body) => { |