clone

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

add the Manage > Agents page with grant cards, recent activity and a signed new-agent form

Dami committed 1351f00
commit
1351f00ce7194a1e524ec9f57707aaafb6e99ccb
parent
80f76c966179c95328cde763f94c3d74261153d0
author
Dami <dami.etoile@gmail.com>
11 files changed, +659 -8
Ainternal/webui/agents_test.go192++++++++++++++++++++++++++++++
Minternal/webui/components.js131++++++++++++++++++++-
Minternal/webui/manage.html49++++++++
Minternal/webui/page.html7+
Minternal/webui/shell_test.go2+-
Minternal/webui/style.css15++
Minternal/webui/templates.go9+-
Minternal/webui/webui.go124+++++++++++++++++++-
Minternal/webui/webui_test.go2+-
Mpackage.json3+-
Ascripts/agent-grant.test.mjs133+++++++++++++++++++++
A internal/webui/agents_test.go +192 -0
@@ -0,0 +1,192 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+// agentsBackend answers listagents with three grants in every state and16+// browseagent with the selected grant plus two events.17+type agentsBackend struct {18+ fakeBackend19+ calls []string20+}21+22+var (23+ agentActive = strings.Repeat("1", 64)24+ agentPaused = strings.Repeat("2", 64)25+ agentRevoked = strings.Repeat("3", 64)26+)27+28+func (b *agentsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {29+ b.calls = append(b.calls, method+":"+actor)30+ owner := b.policy.Owner31+ now := time.Now().Unix()32+ scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any {33+ return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60}34+ }35+ agents := []any{36+ map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120,37+ "scope": scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})},38+ map[string]any{"pubkey": agentPaused, "owner": owner, "name": "reviewer-bot", "expires": now + 86400*30, "paused": true, "revoked": 0, "lastEvent": 0,39+ "scope": scope([]string{"build"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "read"}}, "", nil)},40+ map[string]any{"pubkey": agentRevoked, "owner": strings.Repeat("c", 64), "name": "", "expires": now + 86400*30, "paused": false, "revoked": now - 86400, "lastEvent": now - 86400*2,41+ "scope": scope([]string{"general"}, nil, "", []int{9})},42+ }43+ switch method {44+ case "listagents":45+ return agents, nil46+ case "browseagent":47+ var query struct {48+ Agent string `json:"agent"`49+ }50+ _ = json.Unmarshal(params[0], &query)51+ for _, agent := range agents {52+ if agent.(map[string]any)["pubkey"] == query.Agent {53+ return map[string]any{"agent": agent, "events": []any{54+ map[string]any{"id": strings.Repeat("d", 64), "kind": 9, "pubkey": query.Agent, "created_at": now - 120, "content": "Draft is up as wiki: release-notes-1-4", "tags": [][]string{}},55+ map[string]any{"id": strings.Repeat("e", 64), "kind": 1111, "pubkey": query.Agent, "created_at": now - 3600, "content": "Reviewed PR 23: two nits, see inline.", "tags": [][]string{}},56+ }}, nil57+ }58+ }59+ return nil, context.Canceled60+ }61+ return map[string]any{"method": method}, nil62+}63+64+func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {65+ backend := &agentsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}66+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})67+ if err != nil {68+ t.Fatal(err)69+ }70+ recorder := httptest.NewRecorder()71+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents", nil))72+ body := recorder.Body.String()73+ if recorder.Code != http.StatusOK {74+ t.Fatalf("status=%d body=%s", recorder.Code, body)75+ }76+ if strings.Contains(body, "class=") {77+ t.Fatal("agents page carries a class attribute")78+ }79+ if strings.Contains(body, "·") {80+ t.Fatal("agents page contains a middle dot")81+ }82+ for _, want := range []string{83+ `<a href="/manage/agents" aria-current="page">/agents</a>`,84+ `<table id="rows">`,85+ `<a href="#agent-` + agentActive + `">hermes</a>`,86+ `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621`,87+ `<td data-state="active">active | `,88+ `<td data-state="paused">paused</td>`,89+ `<td data-state="revoked">revoked | `,90+ `<a href="#agent-` + agentRevoked + `">` + shortID(agentRevoked) + `</a>`,91+ `<agent-card id="agent-` + agentActive + `">`,92+ `<h3>hermes <small><span data-state="active">active</span> | signed by you | expires `,93+ `<code>tinyrelay</code> maintain, owner <nostr-key hex="` + backend.policy.Owner + `"`,94+ `<th>rate</th><td>60 events per minute</td>`,95+ `<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Pause</button></rpc-form>`,96+ `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Revoke</button></rpc-form>`,97+ `<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value="&quot;` + agentPaused + `&quot;"><button>Resume</button></rpc-form>`,98+ `<h3>Recent activity of hermes</h3>`,99+ `<ol id="events">`,100+ `<nostr-event id="event-` + strings.Repeat("d", 64) + `">`,101+ `Reviewed PR 23: two nits, see inline.`,102+ `<agent-grant>`,103+ `<select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select>`,104+ `<textarea name="repos"`,105+ `<input name="expires" type="date" value="` + dateAfter(90) + `" required>`,106+ `<h4>Agents</h4><table><tr><th>active</th><td>1</td></tr><tr><th>paused</th><td>1</td></tr><tr><th>revoked</th><td>1</td></tr></table>`,107+ `<code>/mcp</code> 2026-07-28, planned`,108+ `<rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form>`,109+ `<rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form>`,110+ } {111+ if !strings.Contains(body, want) {112+ t.Errorf("agents page missing %q", want)113+ }114+ }115+ // The revoked agent keeps its facts but offers no controls.116+ revokedCard := body[strings.Index(body, `<agent-card id="agent-`+agentRevoked+`">`):]117+ revokedCard = revokedCard[:strings.Index(revokedCard, "</agent-card>")]118+ if strings.Contains(revokedCard, "<rpc-form") || !strings.Contains(revokedCard, `<span data-state="revoked">revoked</span>`) {119+ t.Errorf("revoked card should show state without controls: %s", revokedCard)120+ }121+ if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner {122+ t.Fatalf("backend calls = %s", got)123+ }124+ // ?agent= selects whose activity shows.125+ backend.calls = nil126+ recorder = httptest.NewRecorder()127+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentPaused, nil))128+ if body := recorder.Body.String(); !strings.Contains(body, `<h3>Recent activity of reviewer-bot</h3>`) {129+ t.Fatalf("selected agent activity missing: %s", body[:min(500, len(body))])130+ }131+}132+133+type emptyAgentsBackend struct{ fakeBackend }134+135+func (b *emptyAgentsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {136+ _, _ = b.fakeBackend.Query(ctx, method, params, actor)137+ return []any{}, nil138+}139+140+func TestAgentsPageWithoutAgentsAndForGuests(t *testing.T) {141+ backend := &emptyAgentsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}142+ app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }})143+ if err != nil {144+ t.Fatal(err)145+ }146+ request := httptest.NewRequest(http.MethodGet, "/manage/agents", nil)147+ request.Header.Set("X-Actor", backend.policy.Owner)148+ recorder := httptest.NewRecorder()149+ app.ServeHTTP(recorder, request)150+ body := recorder.Body.String()151+ if recorder.Code != http.StatusOK || !strings.Contains(body, "No agents yet.") || !strings.Contains(body, "<agent-grant>") || strings.Contains(body, "<agent-card") {152+ t.Fatalf("empty agents page: %d %s", recorder.Code, body[:min(600, len(body))])153+ }154+ if backend.call != "listagents:"+backend.policy.Owner {155+ t.Fatalf("empty list should not query an agent: %s", backend.call)156+ }157+158+ recorder = httptest.NewRecorder()159+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents", nil))160+ body = recorder.Body.String()161+ if recorder.Code != http.StatusOK || !strings.Contains(body, "Sign in to manage this relay.") || strings.Contains(body, "<agent-grant>") || strings.Contains(body, "pauseallagents") {162+ t.Fatalf("guest saw the agents page: %d %s", recorder.Code, body[:min(600, len(body))])163+ }164+}165+166+func TestAgentStateHelpers(t *testing.T) {167+ now := time.Now().Unix()168+ for _, tc := range []struct {169+ agent map[string]any170+ state string171+ since string172+ }{173+ {map[string]any{"expires": float64(now + 3600), "lastEvent": float64(now - 60)}, "active", short(now - 60)},174+ {map[string]any{"expires": float64(now + 3600), "paused": true}, "paused", ""},175+ {map[string]any{"expires": float64(now + 3600), "revoked": float64(now - 10)}, "revoked", short(now - 10)},176+ {map[string]any{"expires": float64(now - 10), "paused": true}, "revoked", "expired " + short(now - 10)},177+ } {178+ if got := agentState(tc.agent); got != tc.state {179+ t.Errorf("agentState(%v) = %q, want %q", tc.agent, got, tc.state)180+ }181+ if got := agentSince(tc.agent); got != tc.since {182+ t.Errorf("agentSince(%v) = %q, want %q", tc.agent, got, tc.since)183+ }184+ }185+ if got := agentScope(map[string]any{"scope": map[string]any{}}); got != "profile and relay list only" {186+ t.Errorf("empty scope = %q", got)187+ }188+ counts := agentCounts([]any{map[string]any{"expires": float64(now + 1)}, map[string]any{"expires": float64(now + 1), "paused": true}})189+ if counts["active"] != 1 || counts["paused"] != 1 || counts["revoked"] != 0 {190+ t.Errorf("counts = %v", counts)191+ }192+}
M internal/webui/components.js +128 -3
@@ -2,9 +2,13 @@
22 // light DOM with document APIs, takes plain lowercase attributes, and carries33 // no styling of its own; page CSS targets the element name directly.44 //5-// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params]>5+// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params] [refresh]>66 // One signed NIP-86 management call. Children become the form body; the77 // element appends an <output> status line and a <json-view> result.8+// With refresh, the page reloads in place after the call succeeds.9+// <agent-grant>10+// Signs a kind 30392 agent grant from its fields. Generates the agent11+// key here and shows the nsec once, or takes a pasted public key.812 // <connect-list>913 // The home page cards: lists, reorders, retargets and removes configured1014 // connections and adds from the catalog, saving with setconnections.
@@ -144,6 +148,10 @@
144148 this.report("Signing…");145149 const result = await rpc(method, params, this.getAttribute("action"));146150 this.report("Done.");151+ if (this.hasAttribute("refresh") && tiny.navigate) {152+ await tiny.navigate(globalThis.location?.href);153+ return;154+ }147155 this.show(result);148156 }149157
@@ -383,6 +391,122 @@
383391 }384392 }385393 394+ // AgentGrant signs a kind 30392 agent grant: the agent's key, name, scope395+ // and expiry, signed by the owner or a moderator. The agent key is made396+ // here and its nsec shown once, or pasted as a public key. The secret never397+ // leaves the page; the relay only receives the signed grant.398+ class AgentGrant extends FormElement {399+ connectedCallback() {400+ super.connectedCallback();401+ if (this.toggled || !this.form) return;402+ this.toggled = true;403+ const mode = this.form.elements.key;404+ const toggle = () => { const label = this.form.elements.pubkey?.closest("label"); if (label) label.hidden = mode.value !== "paste"; };405+ mode?.addEventListener("change", toggle);406+ if (mode) toggle();407+ }408+409+ // tags builds the grant tags from the field values; it throws on any410+ // value the relay would refuse so nothing reaches the signer.411+ static tags(values, pubkey, now = Math.floor(Date.now() / 1000)) {412+ const text = name => String(values[name] ?? "").trim();413+ const split = name => text(name).split(/[\s,]+/).map(item => item.trim()).filter(Boolean);414+ if (!isHex64(pubkey)) throw Error("Enter the agent's public key as 64 lowercase hex characters.");415+ const name = text("name");416+ if (name.length > 64) throw Error("Keep the name to 64 characters.");417+ const tags = [["d", pubkey], ["p", pubkey]];418+ if (name) tags.push(["name", name]);419+ const expires = Math.floor(Date.parse(text("expires") + "T23:59:59Z") / 1000);420+ if (!Number.isFinite(expires) || expires <= now) throw Error("Choose an expiry date after today.");421+ if (expires > now + 365 * 86400) throw Error("Grants last at most 365 days.");422+ tags.push(["expiration", String(expires)]);423+ for (const room of [...new Set(split("rooms"))]) {424+ if (room.length > 128) throw Error("Room names are at most 128 characters.");425+ tags.push(["room", room]);426+ }427+ const seenRepos = new Set();428+ for (const line of text("repos").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) {429+ const match = /^([0-9a-f]{64}):(.+):(read|maintain)$/.exec(line);430+ if (!match || match[2].length > 256) throw Error("Repositories are owner pubkey:identifier:read or maintain, one per line.");431+ if (seenRepos.has(match[1] + ":" + match[2])) continue;432+ seenRepos.add(match[1] + ":" + match[2]);433+ tags.push(["repo", line]);434+ }435+ for (const kind of [...new Set(split("kinds"))]) {436+ if (!/^\d{1,5}$/.test(kind) || Number(kind) > 65535) throw Error("Kinds are whole numbers from 0 to 65535.");437+ tags.push(["k", String(Number(kind))]);438+ }439+ const wiki = text("wiki");440+ if (wiki && wiki !== "propose" && wiki !== "edit") throw Error("Wiki access is propose or edit.");441+ if (wiki) tags.push(["wiki", wiki]);442+ const rate = text("rate");443+ if (rate) {444+ if (!/^\d+$/.test(rate) || Number(rate) < 1 || Number(rate) > 600) throw Error("Rate is 1 to 600 events per minute.");445+ tags.push(["rate", String(Number(rate))]);446+ }447+ return tags;448+ }449+450+ async submit(form) {451+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");452+ const value = name => form.elements[name]?.value ?? "";453+ const values = {name: value("name"), expires: value("expires"), rooms: value("rooms"), repos: value("repos"), kinds: value("kinds"), wiki: value("wiki"), rate: value("rate")};454+ let secret = null, pubkey = value("pubkey").trim().toLowerCase();455+ if (value("key") !== "paste") {456+ if (!window.NostrSigner?.generateSecretKey) throw Error("The signer bundle is still loading.");457+ secret = window.NostrSigner.generateSecretKey();458+ pubkey = window.NostrSigner.getPublicKey(secret);459+ }460+ const tags = AgentGrant.tags(values, pubkey);461+ const unsigned = {kind: 30392, created_at: Math.floor(Date.now() / 1000), tags, content: ""};462+ // Keep a separate copy: extensions may mutate their input while signing.463+ const expected = JSON.stringify(unsigned);464+ this.report("Signing…");465+ const event = await window.nostr.signEvent(JSON.parse(expected));466+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});467+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");468+ if (event.pubkey === pubkey) throw Error("The agent needs its own key, not yours.");469+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});470+ const result = await response.json();471+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the grant.");472+ this.report("Granted.");473+ if (secret) {474+ this.reveal(pubkey, secret);475+ return;476+ }477+ form.reset();478+ await tiny.navigate?.(globalThis.location?.href);479+ }480+481+ // reveal shows the generated secret once. The page is not reloaded so482+ // it stays on screen until the person leaves.483+ reveal(pubkey, secret) {484+ const nsec = tiny.util.bech32Encode("nsec", secret);485+ const warning = el("p", "Copy the agent's secret key now. It is not stored anywhere and will not be shown again.");486+ warning.setAttribute("role", "alert");487+ const secretLabel = el("label", "Agent secret key ");488+ secretLabel.setAttribute("data-secret", "");489+ const secretInput = el("input");490+ secretInput.readOnly = true;491+ secretInput.name = "nsec";492+ secretInput.value = nsec;493+ secretInput.addEventListener("focus", () => secretInput.select());494+ secretLabel.append(secretInput);495+ const keyLabel = el("label", "Agent public key ");496+ keyLabel.setAttribute("data-secret", "");497+ const keyInput = el("input");498+ keyInput.readOnly = true;499+ keyInput.name = "agent";500+ keyInput.value = pubkey;501+ keyLabel.append(keyInput);502+ const link = el("a", "Show the new agent");503+ link.href = (globalThis.location?.pathname || "") + "?agent=" + pubkey;504+ const note = el("p");505+ note.append(link);506+ this.append(warning, secretLabel, keyLabel, note);507+ }508+ }509+386510 class NostrKey extends HTMLElement {387511 static get observedAttributes() { return ["hex"]; }388512
@@ -872,6 +996,7 @@
872996 customElements.define("file-tools", FileTools);873997 customElements.define("publish-list", PublishList);874998 customElements.define("nostr-compose", NostrCompose);999+ customElements.define("agent-grant", AgentGrant);8751000 customElements.define("push-toggle", PushToggle);8761001 customElements.define("share-link", ShareLink);8771002 customElements.define("nostr-key", NostrKey);
@@ -902,7 +1027,7 @@
9021027 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;9031028 return root ? url.pathname.slice(root.length) || "/" : url.pathname;9041029 };905- const allowedRoute = path => /^(?:\/(?:inbox|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || "");1030+ const allowedRoute = path => /^(?:\/(?:inbox|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || "");9061031 let navigationSerial = 0, activeAbort;9071032 const streams = new Set();9081033 const closeStreams = () => {
@@ -917,7 +1042,7 @@
9171042 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));9181043 window.addEventListener("pagehide", closeStreams);9191044 const repairComponents = () => {920- document.querySelectorAll("rpc-form,signed-form,publish-list").forEach(node => {1045+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant").forEach(node => {9211046 if (node.form?.isConnected) return;9221047 node.form = null;9231048 node.output = null;
M internal/webui/manage.html +49 -0
@@ -29,6 +29,55 @@
2929 </section>3030 {{end}}3131 32+{{define "agents"}}<section>33+<h1>Agents</h1>34+<p>Agents are members with their own keys. Each one acts under a grant you sign, and stays the author of everything it does.</p>35+{{template "alert" .Error}}36+<table id="rows"><thead><tr><th>agent</th><th>scope</th><th>owner</th><th>state</th></tr></thead><tbody>37+{{range .Feed}}{{$a := asMap .}}{{$pk := str (index $a "pubkey")}}<tr><td><a href="#agent-{{$pk}}">{{agentLabel $a}}</a></td><td title="{{agentScope $a}}">{{agentScope $a}}</td><td>{{template "nostrKey" (str (index $a "owner"))}}</td><td data-state="{{agentState $a}}">{{agentState $a}}{{with agentSince $a}} | {{.}}{{end}}</td></tr>38+{{else}}<tr><td colspan="4">No agents yet. Sign a grant below to add one.</td></tr>39+{{end}}</tbody></table>40+{{range .Feed}}{{$a := asMap .}}{{$pk := str (index $a "pubkey")}}{{$state := agentState $a}}{{$scope := asMap (index $a "scope")}}41+<agent-card id="agent-{{$pk}}">42+<h3>{{agentLabel $a}} <small><span data-state="{{$state}}">{{$state}}</span> | signed by {{if eq (str (index $a "owner")) $.Actor}}you{{else}}{{template "nostrKey" (str (index $a "owner"))}}{{end}} | expires {{short (index $a "expires")}}</small></h3>43+<table>44+<tr><th>key</th><td>{{template "nostrKey" $pk}} <code>{{$pk}}</code></td></tr>45+<tr><th>rooms</th><td>{{or (scopeList $a "rooms") "none"}}</td></tr>46+<tr><th>repositories</th><td>{{range $i, $r := index $scope "repos"}}{{$repo := asMap $r}}{{if $i}}<br>{{end}}<code>{{str (index $repo "identifier")}}</code> {{str (index $repo "level")}}, owner {{template "nostrKey" (str (index $repo "owner"))}}{{else}}none{{end}}</td></tr>47+<tr><th>wiki</th><td>{{or (str (index $scope "wiki")) "none"}}</td></tr>48+<tr><th>kinds</th><td>{{or (scopeList $a "kinds") "profile and relay list only"}}</td></tr>49+<tr><th>rate</th><td>{{str (index $scope "rate")}} events per minute</td></tr>50+<tr><th>expires</th><td><time datetime="{{datetime (index $a "expires")}}">{{when (index $a "expires")}}</time></td></tr>51+<tr><th>owner</th><td>{{template "nostrKey" (str (index $a "owner"))}}</td></tr>52+{{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}}53+</table>54+<p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value="&quot;{{$pk}}&quot;"><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value="&quot;{{$pk}}&quot;"><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value="&quot;{{$pk}}&quot;"><button>Revoke</button></rpc-form>{{end}}<a href="/manage/agents?agent={{$pk}}">recent activity</a></p>55+</agent-card>56+{{end}}{{with asMap .Event}}{{$agent := asMap (index . "agent")}}57+<h3>Recent activity of {{agentLabel $agent}}</h3>58+{{$events := index . "events"}}{{if $events}}<ol id="events">59+{{range $events}}<li>{{template "nostrEvent" .}}</li>60+{{end}}</ol>61+{{else}}<p>No events from this agent yet.</p>62+{{end}}{{end}}63+<h3>New agent</h3>64+<p>The grant is a kind 30392 event signed by your key. The agent's own key signs everything it publishes.</p>65+<agent-grant>66+<label>Name <input name="name" maxlength="64" required placeholder="release-notes"></label>67+<label>Key <select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select></label>68+<label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"></label>69+<label>Rooms <input name="rooms" placeholder="build, agents"></label>70+<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain"></textarea></label>71+<label>Wiki <select name="wiki"><option value="">none</option><option value="propose">propose</option><option value="edit">edit</option></select></label>72+<label>Kinds <input name="kinds" placeholder="1, 1111, 1621"></label>73+<label>Rate per minute <input name="rate" type="number" min="1" max="600" value="60"></label>74+<label>Expires <input name="expires" type="date" value="{{dateAfter 90}}" required></label>75+<button>Sign grant</button>76+<noscript><p>Signing a grant needs JavaScript and a connected signer. You can also publish the kind 30392 event from any client.</p></noscript>77+</agent-grant>78+</section>79+{{end}}80+3281 {{define "moderation"}}<section>3382 <h1>Moderation</h1>3483 <h3>Reports</h3>
M internal/webui/page.html +7 -0
@@ -38,6 +38,7 @@
3838 {{else if eq .Tab "grasp"}}{{template "grasp" .}}3939 {{else if eq .Tab "terms"}}{{template "terms" .}}4040 {{else if eq .Tab "people"}}{{template "people" .}}41+{{else if eq .Tab "agents"}}{{template "agents" .}}4142 {{else if eq .Tab "moderation"}}{{template "moderation" .}}4243 {{else if eq .Tab "rules"}}{{template "rules" .}}4344 {{else if eq .Tab "identity"}}{{template "identity" .}}
@@ -88,6 +89,7 @@
8889 {{/* The panel holds facts and filters for the current page. */}}8990 {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}}9091 {{else if eq .Tab "repo"}}{{template "panel-repo" .}}92+{{else if eq .Tab "agents"}}{{template "panel-agents" .}}9193 {{else if eq $kind "manage"}}{{template "panel-manage" .}}9294 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}9395 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}
@@ -121,6 +123,11 @@
121123 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>122124 <section><h4>Result</h4><p><small>Each form shows its result below the form. Every call is signed by your connected signer.</small></p></section>{{end}}123125 126+{{define "panel-agents"}}{{$counts := agentCounts .Feed}}<section><h4>Agents</h4><table><tr><th>active</th><td>{{index $counts "active"}}</td></tr><tr><th>paused</th><td>{{index $counts "paused"}}</td></tr><tr><th>revoked</th><td>{{index $counts "revoked"}}</td></tr></table></section>127+<section><h4>Machine access</h4><table><tr><th>mcp</th><td><code>/mcp</code> 2026-07-28, planned</td></tr><tr><th>auth</th><td>NIP-98 per request</td></tr><tr><th>rest</th><td><code>/events</code> <code>/query</code></td></tr></table></section>128+<section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Pausing keeps grants and rejects writes until resumed.</small></p></section>129+<section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>{{end}}130+124131 {{/* Shared partials. */}}125132 126133 {{define "alert"}}{{if .}}<p role="alert">{{.}}</p>{{end}}{{end}}
M internal/webui/shell_test.go +1 -1
M internal/webui/style.css +15 -0
@@ -134,6 +134,21 @@ file-workspace-root button{background:transparent;border:0;padding:0;color:var(-
134134 connect-list{display:block}connect-list #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}connect-list #rows td:first-child{width:150px}connect-list #rows td:nth-child(2){color:var(--muted)}connect-list #rows td:nth-child(2) b{color:var(--ink);font-weight:normal}connect-list #rows td:nth-child(3){width:110px}connect-list #rows td:last-child{width:190px;white-space:nowrap}connect-list button,connect-list select{font-size:12px}connect-list output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px}connect-list output[data-error]{color:var(--alert)}135135 relay-lists{display:block}relay-lists #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}relay-lists #rows td:last-child{width:230px;text-align:left;color:var(--ink);white-space:nowrap}relay-lists #rows td:first-child{width:170px;color:var(--ink);font-family:var(--sans)}relay-lists output{display:block;font:12px var(--mono);color:var(--muted);white-space:normal}relay-lists button{font-size:12px}136136 137+/* agent-card: one grant's facts and controls. agent-grant signs a new one. */138+agent-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}139+agent-card h3{margin:0 0 4px;border:0;font-size:15px}agent-card h3 small{font:12px var(--mono);color:var(--muted)}140+agent-card table{border-collapse:collapse;font:13px var(--mono);width:auto;margin:0}141+agent-card th{text-align:left;font-weight:normal;color:var(--muted);width:9em;padding:3px 0;border:0;vertical-align:top}agent-card td{padding:3px 0;border:0;overflow-wrap:anywhere}142+agent-card td code{color:var(--muted)}143+agent-card p{margin:8px 0 0}agent-card rpc-form{display:inline-block;border:0;padding:0;margin:0 8px 0 0;vertical-align:top}agent-card rpc-form output:empty{display:none}144+agent-card p>a{font:12px var(--mono)}145+[data-state=active],#rows td[data-state=active]{color:var(--accent)}[data-state=paused],#rows td[data-state=paused]{color:var(--bark)}[data-state=revoked],#rows td[data-state=revoked]{color:var(--alert)}146+agent-grant{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}147+agent-grant label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}148+agent-grant label input,agent-grant label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}149+agent-grant label:has(textarea),agent-grant label[data-secret]{display:block}agent-grant label[data-secret] input{width:100%}agent-grant label[hidden]{display:none}150+agent-grant output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}agent-grant output[data-error]{color:var(--alert)}151+#panel rpc-form{border:0;padding:4px 0}#panel rpc-form output:empty{display:none}137152 connect-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}connect-card h3{margin:0 0 4px;border:0;font-size:15px}connect-card h3 small{font:12px var(--mono);color:var(--muted)}connect-card p{margin:0 0 6px;color:var(--muted)}connect-card label{display:inline-block;font:13px var(--sans);color:var(--muted);margin:0 10px 6px 0}connect-card label input{display:block;font:13px var(--mono);min-width:200px}connect-card a,connect-card button{margin-right:10px;font-size:13px}connect-card output{display:block;font:12px var(--mono);color:var(--muted);overflow-wrap:anywhere}connect-card details img{display:block;margin:6px 0;background:#fff;padding:6px;border:1px solid var(--line)}138153 139154 /* json-view: structured rendering of any JSON payload. */
M internal/webui/templates.go +8 -1
@@ -106,7 +106,7 @@ var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "sear
106106 107107 // manageNav: who is here, what they may do, what the relay is, what it does108108 // over time, and how it is doing.109-var manageNav = []navItem{{"/people", "/manage/people", "people", 0}, {"/moderation", "/manage/moderation", "moderation", 1}, {"/rules", "/manage/rules", "rules", 1}, {"/identity", "/manage/identity", "identity", 2}, {"/connect", "/manage/connect", "connect", 2}, {"/owner", "/manage/owner", "owner", 2}, {"/sync", "/manage/sync", "sync", 3}, {"/data", "/manage/data", "data", 3}, {"/views", "/manage/views", "views", 3}, {"/health", "/manage/health", "health", 4}}109+var manageNav = []navItem{{"/people", "/manage/people", "people", 0}, {"/agents", "/manage/agents", "agents", 0}, {"/moderation", "/manage/moderation", "moderation", 1}, {"/rules", "/manage/rules", "rules", 1}, {"/identity", "/manage/identity", "identity", 2}, {"/connect", "/manage/connect", "connect", 2}, {"/owner", "/manage/owner", "owner", 2}, {"/sync", "/manage/sync", "sync", 3}, {"/data", "/manage/data", "data", 3}, {"/views", "/manage/views", "views", 3}, {"/health", "/manage/health", "health", 4}}110110 111111 // navGroups splits a rail list into its groups, in order.112112 func navGroups(items []navItem) [][]navItem {
@@ -243,6 +243,13 @@ func parseTemplates() (*template.Template, error) {
243243 "collabItems": collaborationItems,244244 "collabReplies": collaborationReplies,245245 "collabLabels": collaborationLabels,246+ "agentState": agentState,247+ "agentSince": agentSince,248+ "agentScope": agentScope,249+ "agentLabel": agentLabel,250+ "agentCounts": agentCounts,251+ "scopeList": scopeList,252+ "dateAfter": dateAfter,246253 }247254 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")248255 if err != nil {
M internal/webui/webui.go +123 -1
@@ -820,12 +820,134 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
820820 data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor})821821 }822822 }823+ if tab == "agents" && actor != "" {824+ a.agentsPage(request, actor, &data)825+ }823826 if tab != "" {824827 data.Title = tab + " | " + a.backend.Slug()825828 }826829 a.render(writer, request, data)827830 }828831 832+// agentsPage loads the agent list for the table and cards, then the grant833+// and recent events of one agent: the one named by ?agent=, else the first.834+// Both queries are gated for the owner and moderators by the backend.835+func (a *App) agentsPage(request *http.Request, actor string, data *PageData) {836+ result, err := a.backend.Query(request.Context(), "listagents", nil, actor)837+ if err != nil {838+ data.Error = err.Error()839+ return840+ }841+ data.Feed = browseRows(result)842+ selected := request.URL.Query().Get("agent")843+ if selected == "" && len(data.Feed) > 0 {844+ selected = plainString(valueMap(data.Feed[0])["pubkey"])845+ }846+ if selected == "" {847+ return848+ }849+ raw, _ := json.Marshal(map[string]any{"agent": selected})850+ detail, err := a.backend.Query(request.Context(), "browseagent", []json.RawMessage{raw}, actor)851+ if err != nil {852+ data.Error = err.Error()853+ return854+ }855+ data.Event = detail856+}857+858+// agentState folds a grant's marks into one word for data-state: revoked859+// covers expired grants too, since neither lets the agent publish again860+// without a new grant.861+func agentState(agent any) string {862+ values := valueMap(agent)863+ switch {864+ case unixSeconds(values["revoked"]) > 0:865+ return "revoked"866+ case unixSeconds(values["expires"]) > 0 && unixSeconds(values["expires"]) <= time.Now().Unix():867+ return "revoked"868+ case values["paused"] == true:869+ return "paused"870+ default:871+ return "active"872+ }873+}874+875+// agentSince is the short time shown beside the state: when the grant was876+// revoked or expired, else the agent's newest event.877+func agentSince(agent any) string {878+ values := valueMap(agent)879+ if revoked := unixSeconds(values["revoked"]); revoked > 0 {880+ return short(revoked)881+ }882+ if expires := unixSeconds(values["expires"]); expires > 0 && expires <= time.Now().Unix() {883+ return "expired " + short(expires)884+ }885+ if last := short(values["lastEvent"]); last != "" {886+ return last887+ }888+ return ""889+}890+891+// agentLabel is the grant's name, or the shortened key when it has none.892+func agentLabel(agent any) string {893+ values := valueMap(agent)894+ if name := plainString(values["name"]); name != "" {895+ return name896+ }897+ return shortID(plainString(values["pubkey"]))898+}899+900+// scopeList joins one list from the grant scope: rooms or kinds.901+func scopeList(agent any, key string) string {902+ items, _ := valueMap(valueMap(agent)["scope"])[key].([]any)903+ parts := make([]string, 0, len(items))904+ for _, item := range items {905+ parts = append(parts, plainString(item))906+ }907+ return strings.Join(parts, ", ")908+}909+910+// agentScope summarises a grant on one line for the agent table.911+func agentScope(agent any) string {912+ scope := valueMap(valueMap(agent)["scope"])913+ var parts []string914+ if rooms := scopeList(agent, "rooms"); rooms != "" {915+ parts = append(parts, "rooms: "+rooms)916+ }917+ if repos, _ := scope["repos"].([]any); len(repos) > 0 {918+ names := make([]string, 0, len(repos))919+ for _, repo := range repos {920+ values := valueMap(repo)921+ names = append(names, plainString(values["identifier"])+" ("+plainString(values["level"])+")")922+ }923+ parts = append(parts, "repos: "+strings.Join(names, ", "))924+ }925+ if wiki := plainString(scope["wiki"]); wiki != "" {926+ parts = append(parts, "wiki: "+wiki)927+ }928+ if kinds := scopeList(agent, "kinds"); kinds != "" {929+ parts = append(parts, "kinds: "+kinds)930+ }931+ if len(parts) == 0 {932+ return "profile and relay list only"933+ }934+ return strings.Join(parts, " | ")935+}936+937+// agentCounts tallies agents by state for the panel.938+func agentCounts(agents []any) map[string]int {939+ counts := map[string]int{"active": 0, "paused": 0, "revoked": 0}940+ for _, agent := range agents {941+ counts[agentState(agent)]++942+ }943+ return counts944+}945+946+// dateAfter is the date field value for a day count from today, in UTC.947+func dateAfter(days int) string {948+ return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02")949+}950+829951 var supportedMethods = []string{830952 "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains",831953 }
@@ -936,7 +1058,7 @@ func tabForPath(path string) string {
9361058 return "file"9371059 case "signin", "sites":9381060 return path939- case "people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":1061+ case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":9401062 return path9411063 case "inbox", "outbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "terms":9421064 return path
M internal/webui/webui_test.go +1 -1
M package.json +2 -1
A scripts/agent-grant.test.mjs +133 -0
@@ -0,0 +1,133 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+import {finalizeEvent, generateSecretKey, getPublicKey, nip19, verifyEvent} from "nostr-tools";6+7+const owner = generateSecretKey();8+const ownerPubkey = getPublicKey(owner);9+const repoOwner = "b".repeat(64);10+const source = fs.readFileSync("internal/webui/components.js", "utf8");11+const tinySource = fs.readFileSync("internal/webui/tiny.js", "utf8");12+const grantSource = source.slice(source.indexOf(" class AgentGrant "), source.indexOf(" class NostrKey "));13+const today = new Date();14+const dateAfter = days => new Date(today.getTime() + days * 86400000).toISOString().slice(0, 10);15+16+function setup(values = {}, options = {}) {17+ const sent = [], signed = [], appended = [];18+ let resets = 0, navigated = 0;19+ class FormElement {20+ getAttribute() { return null; }21+ report(message) { this.message = message; }22+ append(...nodes) { appended.push(...nodes); }23+ }24+ const el = (name, text) => ({name, text, children: [], attributes: {}, append(...nodes) { this.children.push(...nodes); }, setAttribute(key, value) { this.attributes[key] = value; }, addEventListener() {}});25+ const tinySandbox = {globalThis: {}, crypto: {}, TextEncoder, URL};26+ vm.runInNewContext(tinySource, tinySandbox);27+ const window = {28+ NostrSigner: {verifyEvent, generateSecretKey, getPublicKey},29+ nostr: {signEvent: async event => {30+ signed.push(structuredClone(event));31+ if (options.change) event.tags.push(["k", "7"]);32+ return finalizeEvent(structuredClone(event), options.signer || owner);33+ }}34+ };35+ const tiny = {36+ util: tinySandbox.globalThis.tiny.util,37+ navigate: async () => { navigated++; },38+ signedFetch: async (path, method, body) => {39+ sent.push({path, method, event: JSON.parse(body)});40+ return Response.json(options.result ?? {accepted: true});41+ }42+ };43+ const Constructor = vm.runInNewContext(`${grantSource}\nAgentGrant`, {FormElement, window, tiny, el, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || ""), globalThis: {location: {pathname: "/manage/agents"}}});44+ const component = new Constructor();45+ const form = {elements: Object.fromEntries(Object.entries({key: "generate", name: "helper", expires: dateAfter(90), rooms: "", repos: "", kinds: "", wiki: "", rate: "60", pubkey: "", ...values}).map(([key, value]) => [key, {value}])), reset: () => { resets++; }};46+ return {Constructor, component, form, sent, signed, appended, resets: () => resets, navigated: () => navigated};47+}48+49+test("a generated key yields a signed grant, an nsec shown once and no reload", async () => {50+ const s = setup({rooms: "build, agents", repos: `${repoOwner}:tinyrelay:maintain\n${repoOwner}:docs:read`, kinds: "9, 1111, 1621", wiki: "propose", rate: "30"});51+ await s.component.submit(s.form);52+ assert.equal(s.sent.length, 1);53+ const {event} = s.sent[0];54+ assert.equal(s.sent[0].path, "/events");55+ assert.equal(event.kind, 30392);56+ assert.equal(event.pubkey, ownerPubkey);57+ assert.equal(verifyEvent(event), true);58+ const agent = event.tags[0][1];59+ assert.match(agent, /^[0-9a-f]{64}$/);60+ assert.notEqual(agent, ownerPubkey);61+ const expiration = Number(event.tags.find(tag => tag[0] === "expiration")[1]);62+ const now = Math.floor(Date.now() / 1000);63+ assert.ok(expiration > now + 89 * 86400 && expiration <= now + 91 * 86400, "expiration is about 90 days out");64+ assert.deepEqual(event.tags.filter(tag => tag[0] !== "expiration"), [65+ ["d", agent], ["p", agent], ["name", "helper"],66+ ["room", "build"], ["room", "agents"],67+ ["repo", `${repoOwner}:tinyrelay:maintain`], ["repo", `${repoOwner}:docs:read`],68+ ["k", "9"], ["k", "1111"], ["k", "1621"],69+ ["wiki", "propose"], ["rate", "30"]70+ ]);71+ assert.equal(event.content, "");72+ // The secret is shown once, in a read-only field, and matches the agent key.73+ const secretInput = s.appended.flatMap(node => node.children || []).find(node => typeof node.value === "string" && node.value.startsWith("nsec1"));74+ assert.ok(secretInput, "nsec field appended");75+ assert.equal(secretInput.readOnly, true);76+ const decoded = nip19.decode(secretInput.value);77+ assert.equal(decoded.type, "nsec");78+ assert.equal(getPublicKey(decoded.data), agent);79+ assert.ok(s.appended.some(node => node.attributes?.role === "alert"), "warning shown");80+ assert.equal(s.navigated(), 0);81+ assert.equal(s.resets(), 0);82+ assert.equal(s.component.message, "Granted.");83+});84+85+test("a pasted public key signs the grant and reloads the page", async () => {86+ const agent = "c".repeat(64);87+ const s = setup({key: "paste", pubkey: " " + agent.toUpperCase() + " ", name: "", rate: ""});88+ await s.component.submit(s.form);89+ const {event} = s.sent[0];90+ assert.deepEqual(event.tags.map(tag => tag[0]), ["d", "p", "expiration"]);91+ assert.equal(event.tags[0][1], agent);92+ assert.equal(s.appended.length, 0);93+ assert.equal(s.navigated(), 1);94+ assert.equal(s.resets(), 1);95+});96+97+test("invalid fields are refused before anything is signed", async () => {98+ for (const [values, message] of [99+ [{key: "paste", pubkey: "nope"}, /public key/],100+ [{name: "x".repeat(65)}, /64 characters/],101+ [{expires: dateAfter(-1)}, /after today/],102+ [{expires: dateAfter(400)}, /365 days/],103+ [{repos: "tinyrelay:maintain"}, /Repositories are/],104+ [{repos: `${repoOwner}:tinyrelay:admin`}, /Repositories are/],105+ [{kinds: "1, abc"}, /Kinds are/],106+ [{kinds: "70000"}, /Kinds are/],107+ [{wiki: "delete"}, /propose or edit/],108+ [{rate: "0"}, /1 to 600/],109+ [{rate: "601"}, /1 to 600/]110+ ]) {111+ const s = setup(values);112+ await assert.rejects(s.component.submit(s.form), message);113+ assert.equal(s.signed.length, 0);114+ assert.equal(s.sent.length, 0);115+ }116+});117+118+test("a changed event, a self grant and a relay refusal keep the form", async () => {119+ const changed = setup({}, {change: true});120+ await assert.rejects(changed.component.submit(changed.form), /invalid or changed/);121+ assert.equal(changed.sent.length, 0);122+ const refused = setup({}, {result: {accepted: false, message: "restricted: moderator required"}});123+ await assert.rejects(refused.component.submit(refused.form), /moderator required/);124+ assert.equal(refused.appended.length, 0);125+ assert.equal(refused.resets(), 0);126+});127+128+test("tags dedupe rooms, repositories and kinds", () => {129+ const {Constructor} = setup();130+ const agent = "d".repeat(64);131+ const tags = Constructor.tags({name: " bot ", expires: dateAfter(10), rooms: "a a, b", repos: `${repoOwner}:r:read\n${repoOwner}:r:maintain`, kinds: "1 1 2", wiki: "", rate: ""}, agent);132+ assert.deepEqual(JSON.parse(JSON.stringify(tags)).filter(tag => tag[0] !== "expiration"), [["d", agent], ["p", agent], ["name", "bot"], ["room", "a"], ["room", "b"], ["repo", `${repoOwner}:r:read`], ["k", "1"], ["k", "2"]]);133+});