- commit
- 15e81574622cba751c6ddaa36e3adbe1d82d472a
- parent
- 143080a91f7fced491a8d2d4ef75f25da1d08937
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | internal/ | 44 | +++++++++++++++++++++++------- |
| M | internal/ | 2 | +- |
| M | internal/ | 15 | ++++++++++ |
| M | scripts/ | 22 | +++++++++++++++ |
@@ -1,6 +1,6 @@
11 # Browser tools22 3-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.3+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.44 55 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.66 @@ -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 @@ -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": {}}@@ -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.",@@ -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+});