clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

name the push operation, log delivery counts and expose links, files and notifications to browser agents

Dami committed 15e8157
commit
15e81574622cba751c6ddaa36e3adbe1d82d472a
parent
143080a91f7fced491a8d2d4ef75f25da1d08937
author
Dami <dami.etoile@gmail.com>
5 files changed, +73 -12
Mdocs/webmcp.md2+-
Minternal/daemon/webpush.go44+++++++++++++++++++++++-------
Minternal/telemetry/telemetry.go2+-
Minternal/webui/webmcp.js15++++++++++
Mscripts/webmcp.test.mjs22+++++++++++++++
M docs/webmcp.md +1 -1
M internal/daemon/webpush.go +34 -10
@@ -109,15 +109,27 @@ func (t *Tenant) pushSubject() string {
109109 return t.publicURL110110 }111111 112+// pushHTTP serves the device notification endpoints under one named113+// operation so the diagnostics registry and traces see them as a unit.112114 func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) {115+ ctx, finish := t.app.telemetry.Start(r.Context(), "push")116+ r = r.WithContext(ctx)117+ outcome := "error"118+ defer func() { finish(outcome) }()119+ outcome = t.servePush(w, r)120+}121+122+// servePush returns the telemetry outcome for the request it answered.123+func (t *Tenant) servePush(w http.ResponseWriter, r *http.Request) string {113124 switch {114125 case r.URL.Path == "/push/key" && r.Method == http.MethodGet:115126 keys, err := t.pushVAPIDKeys(r.Context())116127 if err != nil {117128 writeJSON(w, 500, map[string]string{"error": "push keys unavailable"})118- return129+ return "error"119130 }120131 writeJSON(w, 200, map[string]string{"key": keys.PublicKey()})132+ return "ok"121133 case r.URL.Path == "/inbox/seen" && r.Method == http.MethodPost:122134 // Opening the inbox clears the badge. This is the person's own123135 // read marker, so the browser session may set it from this origin.
@@ -129,51 +141,57 @@ func (t *Tenant) pushHTTP(w http.ResponseWriter, r *http.Request) {
129141 }130142 if actor == "" {131143 writeJSON(w, 401, map[string]string{"error": "auth-required: sign this request"})132- return144+ return "unauthorized"133145 }134146 if err := t.markInboxSeen(r.Context(), actor); err != nil {135147 writeJSON(w, 500, map[string]string{"error": "could not record inbox visit"})136- return148+ return "error"137149 }138150 writeJSON(w, 200, map[string]any{"ok": true})151+ return "ok"139152 case (r.URL.Path == "/push/subscribe" || r.URL.Path == "/push/unsubscribe") && r.Method == http.MethodPost:140153 // Registration is a signed mutation like every other change made141154 // from the browser; the session cookie alone is not enough.142155 actor, err := t.resolveUIActor(r)143156 if err != nil || actor == "" {144157 writeJSON(w, 401, map[string]string{"error": "auth-required: sign this request"})145- return158+ return "unauthorized"146159 }147160 body, err := t.requestBody(w, r)148161 if err != nil {149162 writeJSON(w, 400, map[string]string{"error": err.Error()})150- return163+ return "invalid"151164 }152165 registration, err := parsePushRegistration(body)153166 if err != nil {154167 writeJSON(w, 400, map[string]string{"error": "invalid: push subscription"})155- return168+ return "invalid"156169 }157170 subscription := registration.Subscription158171 if r.URL.Path == "/push/unsubscribe" {159172 if _, err := t.store.DB().ExecContext(r.Context(), "DELETE FROM web_push WHERE endpoint=? AND pubkey=?", subscription.Endpoint, actor); err != nil {160173 writeJSON(w, 500, map[string]string{"error": "unsubscribe failed"})161- return174+ return "error"162175 }163176 writeJSON(w, 200, map[string]any{"ok": true})164- return177+ return "ok"165178 }166179 if role, err := t.community.Role(r.Context(), actor); err != nil || (role == "" && actor != t.Policy().Owner) {167180 writeJSON(w, 403, map[string]string{"error": "restricted: notifications are limited to relay members"})168- return181+ return "unauthorized"169182 }170183 if err := t.savePushSubscription(r.Context(), actor, registration); err != nil {171184 writeJSON(w, statusForPush(err), map[string]string{"error": err.Error()})172- return185+ if statusForPush(err) == 400 {186+ return "invalid"187+ }188+ return "error"173189 }174190 writeJSON(w, 200, map[string]any{"ok": true})191+ return "ok"175192 default:176193 http.NotFound(w, r)194+ return "invalid"177195 }178196 }179197
@@ -311,18 +329,24 @@ func (t *Tenant) handleNotificationPush(ctx context.Context, intent work.Intent)
311329 client = replication.NewPinnedClient(10 * time.Second)312330 }313331 var result error332+ delivered, removed, failed := 0, 0, 0314333 for _, item := range devices {315334 sent, sendErr := webpush.Send(ctx, client, keys, t.pushSubject(), item.subscription, message, pushTTL)316335 switch {317336 case sendErr == nil:337+ delivered++318338 _, _ = t.store.DB().ExecContext(ctx, "UPDATE web_push SET failures=0 WHERE endpoint=?", item.endpoint)319339 case sent.Gone || item.failures+1 >= pushMaxFailures:340+ removed++320341 _, _ = t.store.DB().ExecContext(ctx, "DELETE FROM web_push WHERE endpoint=?", item.endpoint)321342 default:343+ failed++322344 _, _ = t.store.DB().ExecContext(ctx, "UPDATE web_push SET failures=failures+1 WHERE endpoint=?", item.endpoint)323345 result = errors.Join(result, sendErr)324346 }325347 }348+ // Counts only: endpoints and keys stay out of logs, as they do for metrics.349+ t.app.telemetry.Logger().Info("device notifications sent", "tenant", t.meta.Name, "category", category, "delivered", delivered, "failed", failed, "removed", removed)326350 return result327351 }328352
M internal/telemetry/telemetry.go +1 -1
@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
236236 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}237237 }238238 239-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": {}}239+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": {}}240240 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}241241 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}242242 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": {}}
M internal/webui/webmcp.js +15 -0
@@ -129,10 +129,25 @@
129129 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.",130130 object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash)));131131 register("tiny.open_status", "Open the relay status page in this tab.", object(), {}, () => open("/manage/status"));132+ register("tiny.open_files", "Open the Files page in this tab, where files and folders upload and shared items wait.", object(), {}, () => open("/files"));133+ 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.",134+ object({target: {type: "string", minLength: 1, maxLength: 512}}, ["target"]), {}, input => open("/open?target=" + encodeURIComponent(input.target)));135+ register("tiny.read_notifications", "Read whether this device receives relay notifications, which categories it chose and the browser permission state.",136+ object(), {readOnlyHint: true}, () => {137+ const categories = (localStorage.getItem("tiny.push.categories") || "").split(",").filter(Boolean);138+ return {139+ supported: "PushManager" in window && "Notification" in window,140+ permission: typeof Notification === "function" ? Notification.permission : "unsupported",141+ enabled: Boolean(localStorage.getItem("tiny.push")),142+ categories: categories.length ? categories : ["messages", "replies", "mentions", "relay"],143+ note: "Enabling notifications needs a person to press Enable on this device under Inbox."144+ };145+ });132146 133147 function control(name, description, schema, method, params) {134148 register(name, description, schema, changes, (input, signal) => manage(method, params(input), signal));135149 }150+ control("tiny.send_test_notification", "Send the relay's test notice to the owner's inbox and enabled devices. Owner only.", object(), "notifytest", () => []);136151 control("tiny.run_job", "Queue an existing job to run now. Inspect job status to check its completion.",137152 object({id}, ["id"]), "runjob", input => [input.id]);138153 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.",
M scripts/webmcp.test.mjs +22 -0
@@ -130,3 +130,25 @@ test("collaboration tools preserve repository, event and filter scope", async ()
130130 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{...repo, ...extra}]);131131 }132132 });133+134+test("link, files and notification tools reach the new surfaces", async () => {135+ const calls = [];136+ const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (url, method, body) => {137+ calls.push({url, method, body: JSON.parse(body)});138+ return new Response(JSON.stringify({result: true}), {status: 200});139+ }});140+ sandbox.localStorage = {getItem: key => ({"tiny.push": "https://push.example/x", "tiny.push.categories": "replies,relay"})[key] ?? null};141+ await tools.get("tiny.open_link").execute({target: "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2"});142+ const opened = new URL(sandbox.opened);143+ assert.equal(opened.pathname, "/r/work/open");144+ assert.equal(opened.searchParams.get("target"), "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2");145+ await tools.get("tiny.open_files").execute({});146+ assert.equal(new URL(sandbox.opened).pathname, "/r/work/files");147+ const status = await tools.get("tiny.read_notifications").execute({});148+ assert.equal(status.structuredContent.result.enabled, true);149+ assert.equal(status.structuredContent.result.categories.join(","), "replies,relay");150+ assert.equal(status.structuredContent.result.permission, "unsupported");151+ await tools.get("tiny.send_test_notification").execute({});152+ assert.equal(calls[0].url, "/r/work/manage/rpc");153+ assert.equal(calls[0].body.method, "notifytest");154+});