| 1 | diff --git a/docs/webmcp.md b/docs/webmcp.md |
| 2 | index 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 | |
| 13 | diff --git a/internal/daemon/webpush.go b/internal/daemon/webpush.go |
| 14 | index 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 | |
| 138 | diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go |
| 139 | index 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": {}} |
| 151 | diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js |
| 152 | index 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.", |
| 181 | diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs |
| 182 | index 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 | +}); |