tinyrelaycommit

Commit ee5e682bb239

browse files at this commit | history

1diff --git a/internal/webui/sw.js b/internal/webui/sw.js
2index 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(); }
33diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go
34index 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
46diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js
47index 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 });
80diff --git a/package.json b/package.json
81index 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",
94diff --git a/scripts/approvals.test.mjs b/scripts/approvals.test.mjs
95new file mode 100644
96index 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+});
274diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs
275index 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) => {

About

ownerc01503e8035f
visibilitypublic
headee5e682bb239

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs