tinyrelaycommit

Commit 15e81574622c

browse files at this commit | history

1diff --git a/docs/webmcp.md b/docs/webmcp.md
2index 3566b53..f20b3f9 100644
3--- a/docs/webmcp.md
4+++ b/docs/webmcp.md
5@@ -1,6 +1,6 @@
6 # Browser tools
7
8-Open **Manage > Tools** to see the tools available to your browser agent. The agent can browse repositories, files, issues and pull requests, inspect service status, manage background jobs, create backups and update relay settings.
9+Open **Manage > Tools** to see the tools available to your browser agent. The agent can browse repositories, files, issues and pull requests, inspect service status, manage background jobs, create backups, update relay settings, open Nostr links and the Files page, read this device's notification state and, as the owner, send a test notification. Turning notifications on stays a manual step, since the browser asks the person for permission.
10
11 Choose **Sign in** in the main navigation and connect your Nostr signer. Reading protected information uses your browser session. Management actions request a signature and follow your account permissions. A queued backup or job has not finished until its status says so.
12
13diff --git a/internal/daemon/webpush.go b/internal/daemon/webpush.go
14index e32c859..93fe7a0 100644
15--- a/internal/daemon/webpush.go
16+++ b/internal/daemon/webpush.go
17@@ -109,15 +109,27 @@ func (t *Tenant) pushSubject() string {
18 return t.publicURL
19 }
20
21+// pushHTTP serves the device notification endpoints under one named
22+// operation so the diagnostics registry and traces see them as a unit.
23 func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) {
24+ ctx, finish := t.app.telemetry.Start(r.Context(), "push")
25+ r = r.WithContext(ctx)
26+ outcome := "error"
27+ defer func() { finish(outcome) }()
28+ outcome = t.servePush(w, r)
29+}
30+
31+// servePush returns the telemetry outcome for the request it answered.
32+func (t *Tenant) servePush(w http.ResponseWriter, r *http.Request) string {
33 switch {
34 case r.URL.Path == "/push/key" && r.Method == http.MethodGet:
35 keys, err := t.pushVAPIDKeys(r.Context())
36 if err != nil {
37 writeJSON(w, 500, map[string]string{"error": "push keys unavailable"})
38- return
39+ return "error"
40 }
41 writeJSON(w, 200, map[string]string{"key": keys.PublicKey()})
42+ return "ok"
43 case r.URL.Path == "/inbox/seen" && r.Method == http.MethodPost:
44 // Opening the inbox clears the badge. This is the person's own
45 // read marker, so the browser session may set it from this origin.
46@@ -129,51 +141,57 @@ func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) {
47 }
48 if actor == "" {
49 writeJSON(w, 401, map[string]string{"error": "auth-required: sign this request"})
50- return
51+ return "unauthorized"
52 }
53 if err := t.markInboxSeen(r.Context(), actor); err != nil {
54 writeJSON(w, 500, map[string]string{"error": "could not record inbox visit"})
55- return
56+ return "error"
57 }
58 writeJSON(w, 200, map[string]any{"ok": true})
59+ return "ok"
60 case (r.URL.Path == "/push/subscribe" || r.URL.Path == "/push/unsubscribe") && r.Method == http.MethodPost:
61 // Registration is a signed mutation like every other change made
62 // from the browser; the session cookie alone is not enough.
63 actor, err := t.resolveUIActor(r)
64 if err != nil || actor == "" {
65 writeJSON(w, 401, map[string]string{"error": "auth-required: sign this request"})
66- return
67+ return "unauthorized"
68 }
69 body, err := t.requestBody(w, r)
70 if err != nil {
71 writeJSON(w, 400, map[string]string{"error": err.Error()})
72- return
73+ return "invalid"
74 }
75 registration, err := parsePushRegistration(body)
76 if err != nil {
77 writeJSON(w, 400, map[string]string{"error": "invalid: push subscription"})
78- return
79+ return "invalid"
80 }
81 subscription := registration.Subscription
82 if r.URL.Path == "/push/unsubscribe" {
83 if _, err := t.store.DB().ExecContext(r.Context(), "DELETE FROM web_push WHERE endpoint=? AND pubkey=?", subscription.Endpoint, actor); err != nil {
84 writeJSON(w, 500, map[string]string{"error": "unsubscribe failed"})
85- return
86+ return "error"
87 }
88 writeJSON(w, 200, map[string]any{"ok": true})
89- return
90+ return "ok"
91 }
92 if role, err := t.community.Role(r.Context(), actor); err != nil || (role == "" && actor != t.Policy().Owner) {
93 writeJSON(w, 403, map[string]string{"error": "restricted: notifications are limited to relay members"})
94- return
95+ return "unauthorized"
96 }
97 if err := t.savePushSubscription(r.Context(), actor, registration); err != nil {
98 writeJSON(w, statusForPush(err), map[string]string{"error": err.Error()})
99- return
100+ if statusForPush(err) == 400 {
101+ return "invalid"
102+ }
103+ return "error"
104 }
105 writeJSON(w, 200, map[string]any{"ok": true})
106+ return "ok"
107 default:
108 http.NotFound(w, r)
109+ return "invalid"
110 }
111 }
112
113@@ -311,18 +329,24 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent)
114 client = replication.NewPinnedClient(10 * time.Second)
115 }
116 var result error
117+ delivered, removed, failed := 0, 0, 0
118 for _, item := range devices {
119 sent, sendErr := webpush.Send(ctx, client, keys, t.pushSubject(), item.subscription, message, pushTTL)
120 switch {
121 case sendErr == nil:
122+ delivered++
123 _, _ = t.store.DB().ExecContext(ctx, "UPDATE web_push SET failures=0 WHERE endpoint=?", item.endpoint)
124 case sent.Gone || item.failures+1 >= pushMaxFailures:
125+ removed++
126 _, _ = t.store.DB().ExecContext(ctx, "DELETE FROM web_push WHERE endpoint=?", item.endpoint)
127 default:
128+ failed++
129 _, _ = t.store.DB().ExecContext(ctx, "UPDATE web_push SET failures=failures+1 WHERE endpoint=?", item.endpoint)
130 result = errors.Join(result, sendErr)
131 }
132 }
133+ // Counts only: endpoints and keys stay out of logs, as they do for metrics.
134+ t.app.telemetry.Logger().Info("device notifications sent", "tenant", t.meta.Name, "category", category, "delivered", delivered, "failed", failed, "removed", removed)
135 return result
136 }
137
138diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
139index 806b068..8fd8c9d 100644
140--- a/internal/telemetry/telemetry.go
141+++ b/internal/telemetry/telemetry.go
142@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
143 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
144 }
145
146-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}}
147+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}}
148 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}
149 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}
150 var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}}
151diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js
152index c0f21fa..e524569 100644
153--- a/internal/webui/webmcp.js
154+++ b/internal/webui/webmcp.js
155@@ -129,10 +129,25 @@
156 register("tiny.open_file", "Open a stored file by its SHA-256 hash in this tab. For repository source, use tiny.open_repository with view=file.",
157 object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash)));
158 register("tiny.open_status", "Open the relay status page in this tab.", object(), {}, () => open("/manage/status"));
159+ register("tiny.open_files", "Open the Files page in this tab, where files and folders upload and shared items wait.", object(), {}, () => open("/files"));
160+ 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.",
161+ object({target: {type: "string", minLength: 1, maxLength: 512}}, ["target"]), {}, input => open("/open?target=" + encodeURIComponent(input.target)));
162+ register("tiny.read_notifications", "Read whether this device receives relay notifications, which categories it chose and the browser permission state.",
163+ object(), {readOnlyHint: true}, () => {
164+ const categories = (localStorage.getItem("tiny.push.categories") || "").split(",").filter(Boolean);
165+ return {
166+ supported: "PushManager" in window && "Notification" in window,
167+ permission: typeof Notification === "function" ? Notification.permission : "unsupported",
168+ enabled: Boolean(localStorage.getItem("tiny.push")),
169+ categories: categories.length ? categories : ["messages", "replies", "mentions", "relay"],
170+ note: "Enabling notifications needs a person to press Enable on this device under Inbox."
171+ };
172+ });
173
174 function control(name, description, schema, method, params) {
175 register(name, description, schema, changes, (input, signal) => manage(method, params(input), signal));
176 }
177+ control("tiny.send_test_notification", "Send the relay's test notice to the owner's inbox and enabled devices. Owner only.", object(), "notifytest", () => []);
178 control("tiny.run_job", "Queue an existing job to run now. Inspect job status to check its completion.",
179 object({id}, ["id"]), "runjob", input => [input.id]);
180 control("tiny.add_job", "Add a background job. every is the interval in hours; 0 runs once. Pull and push jobs need ws:// or wss:// relay URLs; pull may instead discover relays for a public key. Dump and backup jobs may omit relays.",
181diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs
182index 95d5eb4..06e4c9b 100644
183--- a/scripts/webmcp.test.mjs
184+++ b/scripts/webmcp.test.mjs
185@@ -130,3 +130,25 @@ test("collaboration tools preserve repository, event and filter scope", async ()
186 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{...repo, ...extra}]);
187 }
188 });
189+
190+test("link, files and notification tools reach the new surfaces", async () => {
191+ const calls = [];
192+ const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (url, method, body) => {
193+ calls.push({url, method, body: JSON.parse(body)});
194+ return new Response(JSON.stringify({result: true}), {status: 200});
195+ }});
196+ sandbox.localStorage = {getItem: key => ({"tiny.push": "https://push.example/x", "tiny.push.categories": "replies,relay"})[key] ?? null};
197+ await tools.get("tiny.open_link").execute({target: "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2"});
198+ const opened = new URL(sandbox.opened);
199+ assert.equal(opened.pathname, "/r/work/open");
200+ assert.equal(opened.searchParams.get("target"), "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2");
201+ await tools.get("tiny.open_files").execute({});
202+ assert.equal(new URL(sandbox.opened).pathname, "/r/work/files");
203+ const status = await tools.get("tiny.read_notifications").execute({});
204+ assert.equal(status.structuredContent.result.enabled, true);
205+ assert.equal(status.structuredContent.result.categories.join(","), "replies,relay");
206+ assert.equal(status.structuredContent.result.permission, "unsupported");
207+ await tools.get("tiny.send_test_notification").execute({});
208+ assert.equal(calls[0].url, "/r/work/manage/rpc");
209+ assert.equal(calls[0].body.method, "notifytest");
210+});

About

ownerc01503e8035f
visibilitypublic
head15e81574622c

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs