tinyrelaycommit

Commit 1351f00ce719

browse files at this commit | history

1diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go
2new file mode 100644
3index 0000000..248124b
4--- /dev/null
5+++ b/internal/webui/agents_test.go
6@@ -0,0 +1,192 @@
7+package webui
8+
9+import (
10+ "context"
11+ "encoding/json"
12+ "net/http"
13+ "net/http/httptest"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
19+)
20+
21+// agentsBackend answers listagents with three grants in every state and
22+// browseagent with the selected grant plus two events.
23+type agentsBackend struct {
24+ fakeBackend
25+ calls []string
26+}
27+
28+var (
29+ agentActive = strings.Repeat("1", 64)
30+ agentPaused = strings.Repeat("2", 64)
31+ agentRevoked = strings.Repeat("3", 64)
32+)
33+
34+func (b *agentsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
35+ b.calls = append(b.calls, method+":"+actor)
36+ owner := b.policy.Owner
37+ now := time.Now().Unix()
38+ scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any {
39+ return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60}
40+ }
41+ agents := []any{
42+ map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120,
43+ "scope": scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})},
44+ map[string]any{"pubkey": agentPaused, "owner": owner, "name": "reviewer-bot", "expires": now + 86400*30, "paused": true, "revoked": 0, "lastEvent": 0,
45+ "scope": scope([]string{"build"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "read"}}, "", nil)},
46+ map[string]any{"pubkey": agentRevoked, "owner": strings.Repeat("c", 64), "name": "", "expires": now + 86400*30, "paused": false, "revoked": now - 86400, "lastEvent": now - 86400*2,
47+ "scope": scope([]string{"general"}, nil, "", []int{9})},
48+ }
49+ switch method {
50+ case "listagents":
51+ return agents, nil
52+ case "browseagent":
53+ var query struct {
54+ Agent string `json:"agent"`
55+ }
56+ _ = json.Unmarshal(params[0], &query)
57+ for _, agent := range agents {
58+ if agent.(map[string]any)["pubkey"] == query.Agent {
59+ return map[string]any{"agent": agent, "events": []any{
60+ 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{}},
61+ 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{}},
62+ }}, nil
63+ }
64+ }
65+ return nil, context.Canceled
66+ }
67+ return map[string]any{"method": method}, nil
68+}
69+
70+func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
71+ backend := &agentsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
72+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ recorder := httptest.NewRecorder()
77+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents", nil))
78+ body := recorder.Body.String()
79+ if recorder.Code != http.StatusOK {
80+ t.Fatalf("status=%d body=%s", recorder.Code, body)
81+ }
82+ if strings.Contains(body, "class=") {
83+ t.Fatal("agents page carries a class attribute")
84+ }
85+ if strings.Contains(body, "·") {
86+ t.Fatal("agents page contains a middle dot")
87+ }
88+ for _, want := range []string{
89+ `<a href="/manage/agents" aria-current="page">/agents</a>`,
90+ `<table id="rows">`,
91+ `<a href="#agent-` + agentActive + `">hermes</a>`,
92+ `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621`,
93+ `<td data-state="active">active | `,
94+ `<td data-state="paused">paused</td>`,
95+ `<td data-state="revoked">revoked | `,
96+ `<a href="#agent-` + agentRevoked + `">` + shortID(agentRevoked) + `</a>`,
97+ `<agent-card id="agent-` + agentActive + `">`,
98+ `<h3>hermes <small><span data-state="active">active</span> | signed by you | expires `,
99+ `<code>tinyrelay</code> maintain, owner <nostr-key hex="` + backend.policy.Owner + `"`,
100+ `<th>rate</th><td>60 events per minute</td>`,
101+ `<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Pause</button></rpc-form>`,
102+ `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Revoke</button></rpc-form>`,
103+ `<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value="&quot;` + agentPaused + `&quot;"><button>Resume</button></rpc-form>`,
104+ `<h3>Recent activity of hermes</h3>`,
105+ `<ol id="events">`,
106+ `<nostr-event id="event-` + strings.Repeat("d", 64) + `">`,
107+ `Reviewed PR 23: two nits, see inline.`,
108+ `<agent-grant>`,
109+ `<select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select>`,
110+ `<textarea name="repos"`,
111+ `<input name="expires" type="date" value="` + dateAfter(90) + `" required>`,
112+ `<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>`,
113+ `<code>/mcp</code> 2026-07-28, planned`,
114+ `<rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form>`,
115+ `<rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form>`,
116+ } {
117+ if !strings.Contains(body, want) {
118+ t.Errorf("agents page missing %q", want)
119+ }
120+ }
121+ // The revoked agent keeps its facts but offers no controls.
122+ revokedCard := body[strings.Index(body, `<agent-card id="agent-`+agentRevoked+`">`):]
123+ revokedCard = revokedCard[:strings.Index(revokedCard, "</agent-card>")]
124+ if strings.Contains(revokedCard, "<rpc-form") || !strings.Contains(revokedCard, `<span data-state="revoked">revoked</span>`) {
125+ t.Errorf("revoked card should show state without controls: %s", revokedCard)
126+ }
127+ if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner {
128+ t.Fatalf("backend calls = %s", got)
129+ }
130+ // ?agent= selects whose activity shows.
131+ backend.calls = nil
132+ recorder = httptest.NewRecorder()
133+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentPaused, nil))
134+ if body := recorder.Body.String(); !strings.Contains(body, `<h3>Recent activity of reviewer-bot</h3>`) {
135+ t.Fatalf("selected agent activity missing: %s", body[:min(500, len(body))])
136+ }
137+}
138+
139+type emptyAgentsBackend struct{ fakeBackend }
140+
141+func (b *emptyAgentsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {
142+ _, _ = b.fakeBackend.Query(ctx, method, params, actor)
143+ return []any{}, nil
144+}
145+
146+func TestAgentsPageWithoutAgentsAndForGuests(t *testing.T) {
147+ backend := &emptyAgentsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
148+ app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }})
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ request := httptest.NewRequest(http.MethodGet, "/manage/agents", nil)
153+ request.Header.Set("X-Actor", backend.policy.Owner)
154+ recorder := httptest.NewRecorder()
155+ app.ServeHTTP(recorder, request)
156+ body := recorder.Body.String()
157+ if recorder.Code != http.StatusOK || !strings.Contains(body, "No agents yet.") || !strings.Contains(body, "<agent-grant>") || strings.Contains(body, "<agent-card") {
158+ t.Fatalf("empty agents page: %d %s", recorder.Code, body[:min(600, len(body))])
159+ }
160+ if backend.call != "listagents:"+backend.policy.Owner {
161+ t.Fatalf("empty list should not query an agent: %s", backend.call)
162+ }
163+
164+ recorder = httptest.NewRecorder()
165+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents", nil))
166+ body = recorder.Body.String()
167+ if recorder.Code != http.StatusOK || !strings.Contains(body, "Sign in to manage this relay.") || strings.Contains(body, "<agent-grant>") || strings.Contains(body, "pauseallagents") {
168+ t.Fatalf("guest saw the agents page: %d %s", recorder.Code, body[:min(600, len(body))])
169+ }
170+}
171+
172+func TestAgentStateHelpers(t *testing.T) {
173+ now := time.Now().Unix()
174+ for _, tc := range []struct {
175+ agent map[string]any
176+ state string
177+ since string
178+ }{
179+ {map[string]any{"expires": float64(now + 3600), "lastEvent": float64(now - 60)}, "active", short(now - 60)},
180+ {map[string]any{"expires": float64(now + 3600), "paused": true}, "paused", ""},
181+ {map[string]any{"expires": float64(now + 3600), "revoked": float64(now - 10)}, "revoked", short(now - 10)},
182+ {map[string]any{"expires": float64(now - 10), "paused": true}, "revoked", "expired " + short(now - 10)},
183+ } {
184+ if got := agentState(tc.agent); got != tc.state {
185+ t.Errorf("agentState(%v) = %q, want %q", tc.agent, got, tc.state)
186+ }
187+ if got := agentSince(tc.agent); got != tc.since {
188+ t.Errorf("agentSince(%v) = %q, want %q", tc.agent, got, tc.since)
189+ }
190+ }
191+ if got := agentScope(map[string]any{"scope": map[string]any{}}); got != "profile and relay list only" {
192+ t.Errorf("empty scope = %q", got)
193+ }
194+ counts := agentCounts([]any{map[string]any{"expires": float64(now + 1)}, map[string]any{"expires": float64(now + 1), "paused": true}})
195+ if counts["active"] != 1 || counts["paused"] != 1 || counts["revoked"] != 0 {
196+ t.Errorf("counts = %v", counts)
197+ }
198+}
199diff --git a/internal/webui/components.js b/internal/webui/components.js
200index 933661f..3709700 100644
201--- a/internal/webui/components.js
202+++ b/internal/webui/components.js
203@@ -2,9 +2,13 @@
204 // light DOM with document APIs, takes plain lowercase attributes, and carries
205 // no styling of its own; page CSS targets the element name directly.
206 //
207-// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params]>
208+// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params] [refresh]>
209 // One signed NIP-86 management call. Children become the form body; the
210 // element appends an <output> status line and a <json-view> result.
211+// With refresh, the page reloads in place after the call succeeds.
212+// <agent-grant>
213+// Signs a kind 30392 agent grant from its fields. Generates the agent
214+// key here and shows the nsec once, or takes a pasted public key.
215 // <connect-list>
216 // The home page cards: lists, reorders, retargets and removes configured
217 // connections and adds from the catalog, saving with setconnections.
218@@ -144,6 +148,10 @@
219 this.report("Signing…");
220 const result = await rpc(method, params, this.getAttribute("action"));
221 this.report("Done.");
222+ if (this.hasAttribute("refresh") && tiny.navigate) {
223+ await tiny.navigate(globalThis.location?.href);
224+ return;
225+ }
226 this.show(result);
227 }
228
229@@ -383,6 +391,122 @@
230 }
231 }
232
233+ // AgentGrant signs a kind 30392 agent grant: the agent's key, name, scope
234+ // and expiry, signed by the owner or a moderator. The agent key is made
235+ // here and its nsec shown once, or pasted as a public key. The secret never
236+ // leaves the page; the relay only receives the signed grant.
237+ class AgentGrant extends FormElement {
238+ connectedCallback() {
239+ super.connectedCallback();
240+ if (this.toggled || !this.form) return;
241+ this.toggled = true;
242+ const mode = this.form.elements.key;
243+ const toggle = () => { const label = this.form.elements.pubkey?.closest("label"); if (label) label.hidden = mode.value !== "paste"; };
244+ mode?.addEventListener("change", toggle);
245+ if (mode) toggle();
246+ }
247+
248+ // tags builds the grant tags from the field values; it throws on any
249+ // value the relay would refuse so nothing reaches the signer.
250+ static tags(values, pubkey, now = Math.floor(Date.now() / 1000)) {
251+ const text = name => String(values[name] ?? "").trim();
252+ const split = name => text(name).split(/[\s,]+/).map(item => item.trim()).filter(Boolean);
253+ if (!isHex64(pubkey)) throw Error("Enter the agent's public key as 64 lowercase hex characters.");
254+ const name = text("name");
255+ if (name.length > 64) throw Error("Keep the name to 64 characters.");
256+ const tags = [["d", pubkey], ["p", pubkey]];
257+ if (name) tags.push(["name", name]);
258+ const expires = Math.floor(Date.parse(text("expires") + "T23:59:59Z") / 1000);
259+ if (!Number.isFinite(expires) || expires <= now) throw Error("Choose an expiry date after today.");
260+ if (expires > now + 365 * 86400) throw Error("Grants last at most 365 days.");
261+ tags.push(["expiration", String(expires)]);
262+ for (const room of [...new Set(split("rooms"))]) {
263+ if (room.length > 128) throw Error("Room names are at most 128 characters.");
264+ tags.push(["room", room]);
265+ }
266+ const seenRepos = new Set();
267+ for (const line of text("repos").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) {
268+ const match = /^([0-9a-f]{64}):(.+):(read|maintain)$/.exec(line);
269+ if (!match || match[2].length > 256) throw Error("Repositories are owner pubkey:identifier:read or maintain, one per line.");
270+ if (seenRepos.has(match[1] + ":" + match[2])) continue;
271+ seenRepos.add(match[1] + ":" + match[2]);
272+ tags.push(["repo", line]);
273+ }
274+ for (const kind of [...new Set(split("kinds"))]) {
275+ if (!/^\d{1,5}$/.test(kind) || Number(kind) > 65535) throw Error("Kinds are whole numbers from 0 to 65535.");
276+ tags.push(["k", String(Number(kind))]);
277+ }
278+ const wiki = text("wiki");
279+ if (wiki && wiki !== "propose" && wiki !== "edit") throw Error("Wiki access is propose or edit.");
280+ if (wiki) tags.push(["wiki", wiki]);
281+ const rate = text("rate");
282+ if (rate) {
283+ if (!/^\d+$/.test(rate) || Number(rate) < 1 || Number(rate) > 600) throw Error("Rate is 1 to 600 events per minute.");
284+ tags.push(["rate", String(Number(rate))]);
285+ }
286+ return tags;
287+ }
288+
289+ async submit(form) {
290+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
291+ const value = name => form.elements[name]?.value ?? "";
292+ const values = {name: value("name"), expires: value("expires"), rooms: value("rooms"), repos: value("repos"), kinds: value("kinds"), wiki: value("wiki"), rate: value("rate")};
293+ let secret = null, pubkey = value("pubkey").trim().toLowerCase();
294+ if (value("key") !== "paste") {
295+ if (!window.NostrSigner?.generateSecretKey) throw Error("The signer bundle is still loading.");
296+ secret = window.NostrSigner.generateSecretKey();
297+ pubkey = window.NostrSigner.getPublicKey(secret);
298+ }
299+ const tags = AgentGrant.tags(values, pubkey);
300+ const unsigned = {kind: 30392, created_at: Math.floor(Date.now() / 1000), tags, content: ""};
301+ // Keep a separate copy: extensions may mutate their input while signing.
302+ const expected = JSON.stringify(unsigned);
303+ this.report("Signing…");
304+ const event = await window.nostr.signEvent(JSON.parse(expected));
305+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});
306+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");
307+ if (event.pubkey === pubkey) throw Error("The agent needs its own key, not yours.");
308+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
309+ const result = await response.json();
310+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the grant.");
311+ this.report("Granted.");
312+ if (secret) {
313+ this.reveal(pubkey, secret);
314+ return;
315+ }
316+ form.reset();
317+ await tiny.navigate?.(globalThis.location?.href);
318+ }
319+
320+ // reveal shows the generated secret once. The page is not reloaded so
321+ // it stays on screen until the person leaves.
322+ reveal(pubkey, secret) {
323+ const nsec = tiny.util.bech32Encode("nsec", secret);
324+ const warning = el("p", "Copy the agent's secret key now. It is not stored anywhere and will not be shown again.");
325+ warning.setAttribute("role", "alert");
326+ const secretLabel = el("label", "Agent secret key ");
327+ secretLabel.setAttribute("data-secret", "");
328+ const secretInput = el("input");
329+ secretInput.readOnly = true;
330+ secretInput.name = "nsec";
331+ secretInput.value = nsec;
332+ secretInput.addEventListener("focus", () => secretInput.select());
333+ secretLabel.append(secretInput);
334+ const keyLabel = el("label", "Agent public key ");
335+ keyLabel.setAttribute("data-secret", "");
336+ const keyInput = el("input");
337+ keyInput.readOnly = true;
338+ keyInput.name = "agent";
339+ keyInput.value = pubkey;
340+ keyLabel.append(keyInput);
341+ const link = el("a", "Show the new agent");
342+ link.href = (globalThis.location?.pathname || "") + "?agent=" + pubkey;
343+ const note = el("p");
344+ note.append(link);
345+ this.append(warning, secretLabel, keyLabel, note);
346+ }
347+ }
348+
349 class NostrKey extends HTMLElement {
350 static get observedAttributes() { return ["hex"]; }
351
352@@ -872,6 +996,7 @@
353 customElements.define("file-tools", FileTools);
354 customElements.define("publish-list", PublishList);
355 customElements.define("nostr-compose", NostrCompose);
356+ customElements.define("agent-grant", AgentGrant);
357 customElements.define("push-toggle", PushToggle);
358 customElements.define("share-link", ShareLink);
359 customElements.define("nostr-key", NostrKey);
360@@ -902,7 +1027,7 @@
361 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
362 return root ? url.pathname.slice(root.length) || "/" : url.pathname;
363 };
364- 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 || "");
365+ 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 || "");
366 let navigationSerial = 0, activeAbort;
367 const streams = new Set();
368 const closeStreams = () => {
369@@ -917,7 +1042,7 @@
370 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));
371 window.addEventListener("pagehide", closeStreams);
372 const repairComponents = () => {
373- document.querySelectorAll("rpc-form,signed-form,publish-list").forEach(node => {
374+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant").forEach(node => {
375 if (node.form?.isConnected) return;
376 node.form = null;
377 node.output = null;
378diff --git a/internal/webui/manage.html b/internal/webui/manage.html
379index 80ba1d8..92f9d41 100644
380--- a/internal/webui/manage.html
381+++ b/internal/webui/manage.html
382@@ -29,6 +29,55 @@
383 </section>
384 {{end}}
385
386+{{define "agents"}}<section>
387+<h1>Agents</h1>
388+<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>
389+{{template "alert" .Error}}
390+<table id="rows"><thead><tr><th>agent</th><th>scope</th><th>owner</th><th>state</th></tr></thead><tbody>
391+{{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>
392+{{else}}<tr><td colspan="4">No agents yet. Sign a grant below to add one.</td></tr>
393+{{end}}</tbody></table>
394+{{range .Feed}}{{$a := asMap .}}{{$pk := str (index $a "pubkey")}}{{$state := agentState $a}}{{$scope := asMap (index $a "scope")}}
395+<agent-card id="agent-{{$pk}}">
396+<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>
397+<table>
398+<tr><th>key</th><td>{{template "nostrKey" $pk}} <code>{{$pk}}</code></td></tr>
399+<tr><th>rooms</th><td>{{or (scopeList $a "rooms") "none"}}</td></tr>
400+<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>
401+<tr><th>wiki</th><td>{{or (str (index $scope "wiki")) "none"}}</td></tr>
402+<tr><th>kinds</th><td>{{or (scopeList $a "kinds") "profile and relay list only"}}</td></tr>
403+<tr><th>rate</th><td>{{str (index $scope "rate")}} events per minute</td></tr>
404+<tr><th>expires</th><td><time datetime="{{datetime (index $a "expires")}}">{{when (index $a "expires")}}</time></td></tr>
405+<tr><th>owner</th><td>{{template "nostrKey" (str (index $a "owner"))}}</td></tr>
406+{{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}}
407+</table>
408+<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>
409+</agent-card>
410+{{end}}{{with asMap .Event}}{{$agent := asMap (index . "agent")}}
411+<h3>Recent activity of {{agentLabel $agent}}</h3>
412+{{$events := index . "events"}}{{if $events}}<ol id="events">
413+{{range $events}}<li>{{template "nostrEvent" .}}</li>
414+{{end}}</ol>
415+{{else}}<p>No events from this agent yet.</p>
416+{{end}}{{end}}
417+<h3>New agent</h3>
418+<p>The grant is a kind 30392 event signed by your key. The agent's own key signs everything it publishes.</p>
419+<agent-grant>
420+<label>Name <input name="name" maxlength="64" required placeholder="release-notes"></label>
421+<label>Key <select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select></label>
422+<label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"></label>
423+<label>Rooms <input name="rooms" placeholder="build, agents"></label>
424+<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain"></textarea></label>
425+<label>Wiki <select name="wiki"><option value="">none</option><option value="propose">propose</option><option value="edit">edit</option></select></label>
426+<label>Kinds <input name="kinds" placeholder="1, 1111, 1621"></label>
427+<label>Rate per minute <input name="rate" type="number" min="1" max="600" value="60"></label>
428+<label>Expires <input name="expires" type="date" value="{{dateAfter 90}}" required></label>
429+<button>Sign grant</button>
430+<noscript><p>Signing a grant needs JavaScript and a connected signer. You can also publish the kind 30392 event from any client.</p></noscript>
431+</agent-grant>
432+</section>
433+{{end}}
434+
435 {{define "moderation"}}<section>
436 <h1>Moderation</h1>
437 <h3>Reports</h3>
438diff --git a/internal/webui/page.html b/internal/webui/page.html
439index 6945843..25eab53 100644
440--- a/internal/webui/page.html
441+++ b/internal/webui/page.html
442@@ -38,6 +38,7 @@
443 {{else if eq .Tab "grasp"}}{{template "grasp" .}}
444 {{else if eq .Tab "terms"}}{{template "terms" .}}
445 {{else if eq .Tab "people"}}{{template "people" .}}
446+{{else if eq .Tab "agents"}}{{template "agents" .}}
447 {{else if eq .Tab "moderation"}}{{template "moderation" .}}
448 {{else if eq .Tab "rules"}}{{template "rules" .}}
449 {{else if eq .Tab "identity"}}{{template "identity" .}}
450@@ -88,6 +89,7 @@
451 {{/* The panel holds facts and filters for the current page. */}}
452 {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}}
453 {{else if eq .Tab "repo"}}{{template "panel-repo" .}}
454+{{else if eq .Tab "agents"}}{{template "panel-agents" .}}
455 {{else if eq $kind "manage"}}{{template "panel-manage" .}}
456 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}
457 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}
458@@ -121,6 +123,11 @@
459 <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>
460 <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}}
461
462+{{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>
463+<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>
464+<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>
465+<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}}
466+
467 {{/* Shared partials. */}}
468
469 {{define "alert"}}{{if .}}<p role="alert">{{.}}</p>{{end}}{{end}}
470diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
471index f2ce18a..7325244 100644
472--- a/internal/webui/shell_test.go
473+++ b/internal/webui/shell_test.go
474@@ -299,7 +299,7 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
475 if err != nil {
476 t.Fatal(err)
477 }
478- for _, path := range []string{"/manage/people", "/manage/owner", "/manage/health", "/manage/data"} {
479+ for _, path := range []string{"/manage/people", "/manage/agents", "/manage/owner", "/manage/health", "/manage/data"} {
480 recorder := httptest.NewRecorder()
481 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
482 body := recorder.Body.String()
483diff --git a/internal/webui/style.css b/internal/webui/style.css
484index f6ef063..0a46694 100644
485--- a/internal/webui/style.css
486+++ b/internal/webui/style.css
487@@ -134,6 +134,21 @@ file-workspace-root button{background:transparent;border:0;padding:0;color:var(-
488 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)}
489 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}
490
491+/* agent-card: one grant's facts and controls. agent-grant signs a new one. */
492+agent-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}
493+agent-card h3{margin:0 0 4px;border:0;font-size:15px}agent-card h3 small{font:12px var(--mono);color:var(--muted)}
494+agent-card table{border-collapse:collapse;font:13px var(--mono);width:auto;margin:0}
495+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}
496+agent-card td code{color:var(--muted)}
497+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}
498+agent-card p>a{font:12px var(--mono)}
499+[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)}
500+agent-grant{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}
501+agent-grant label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}
502+agent-grant label input,agent-grant label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
503+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}
504+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)}
505+#panel rpc-form{border:0;padding:4px 0}#panel rpc-form output:empty{display:none}
506 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)}
507
508 /* json-view: structured rendering of any JSON payload. */
509diff --git a/internal/webui/templates.go b/internal/webui/templates.go
510index fd6dee0..0c6abcc 100644
511--- a/internal/webui/templates.go
512+++ b/internal/webui/templates.go
513@@ -106,7 +106,7 @@ var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "sear
514
515 // manageNav: who is here, what they may do, what the relay is, what it does
516 // over time, and how it is doing.
517-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}}
518+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}}
519
520 // navGroups splits a rail list into its groups, in order.
521 func navGroups(items []navItem) [][]navItem {
522@@ -243,6 +243,13 @@ func parseTemplates() (*template.Template, error) {
523 "collabItems": collaborationItems,
524 "collabReplies": collaborationReplies,
525 "collabLabels": collaborationLabels,
526+ "agentState": agentState,
527+ "agentSince": agentSince,
528+ "agentScope": agentScope,
529+ "agentLabel": agentLabel,
530+ "agentCounts": agentCounts,
531+ "scopeList": scopeList,
532+ "dateAfter": dateAfter,
533 }
534 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")
535 if err != nil {
536diff --git a/internal/webui/webui.go b/internal/webui/webui.go
537index 458120d..e89cd0b 100644
538--- a/internal/webui/webui.go
539+++ b/internal/webui/webui.go
540@@ -820,12 +820,134 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
541 data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor})
542 }
543 }
544+ if tab == "agents" && actor != "" {
545+ a.agentsPage(request, actor, &data)
546+ }
547 if tab != "" {
548 data.Title = tab + " | " + a.backend.Slug()
549 }
550 a.render(writer, request, data)
551 }
552
553+// agentsPage loads the agent list for the table and cards, then the grant
554+// and recent events of one agent: the one named by ?agent=, else the first.
555+// Both queries are gated for the owner and moderators by the backend.
556+func (a *App) agentsPage(request *http.Request, actor string, data *PageData) {
557+ result, err := a.backend.Query(request.Context(), "listagents", nil, actor)
558+ if err != nil {
559+ data.Error = err.Error()
560+ return
561+ }
562+ data.Feed = browseRows(result)
563+ selected := request.URL.Query().Get("agent")
564+ if selected == "" && len(data.Feed) > 0 {
565+ selected = plainString(valueMap(data.Feed[0])["pubkey"])
566+ }
567+ if selected == "" {
568+ return
569+ }
570+ raw, _ := json.Marshal(map[string]any{"agent": selected})
571+ detail, err := a.backend.Query(request.Context(), "browseagent", []json.RawMessage{raw}, actor)
572+ if err != nil {
573+ data.Error = err.Error()
574+ return
575+ }
576+ data.Event = detail
577+}
578+
579+// agentState folds a grant's marks into one word for data-state: revoked
580+// covers expired grants too, since neither lets the agent publish again
581+// without a new grant.
582+func agentState(agent any) string {
583+ values := valueMap(agent)
584+ switch {
585+ case unixSeconds(values["revoked"]) > 0:
586+ return "revoked"
587+ case unixSeconds(values["expires"]) > 0 && unixSeconds(values["expires"]) <= time.Now().Unix():
588+ return "revoked"
589+ case values["paused"] == true:
590+ return "paused"
591+ default:
592+ return "active"
593+ }
594+}
595+
596+// agentSince is the short time shown beside the state: when the grant was
597+// revoked or expired, else the agent's newest event.
598+func agentSince(agent any) string {
599+ values := valueMap(agent)
600+ if revoked := unixSeconds(values["revoked"]); revoked > 0 {
601+ return short(revoked)
602+ }
603+ if expires := unixSeconds(values["expires"]); expires > 0 && expires <= time.Now().Unix() {
604+ return "expired " + short(expires)
605+ }
606+ if last := short(values["lastEvent"]); last != "" {
607+ return last
608+ }
609+ return ""
610+}
611+
612+// agentLabel is the grant's name, or the shortened key when it has none.
613+func agentLabel(agent any) string {
614+ values := valueMap(agent)
615+ if name := plainString(values["name"]); name != "" {
616+ return name
617+ }
618+ return shortID(plainString(values["pubkey"]))
619+}
620+
621+// scopeList joins one list from the grant scope: rooms or kinds.
622+func scopeList(agent any, key string) string {
623+ items, _ := valueMap(valueMap(agent)["scope"])[key].([]any)
624+ parts := make([]string, 0, len(items))
625+ for _, item := range items {
626+ parts = append(parts, plainString(item))
627+ }
628+ return strings.Join(parts, ", ")
629+}
630+
631+// agentScope summarises a grant on one line for the agent table.
632+func agentScope(agent any) string {
633+ scope := valueMap(valueMap(agent)["scope"])
634+ var parts []string
635+ if rooms := scopeList(agent, "rooms"); rooms != "" {
636+ parts = append(parts, "rooms: "+rooms)
637+ }
638+ if repos, _ := scope["repos"].([]any); len(repos) > 0 {
639+ names := make([]string, 0, len(repos))
640+ for _, repo := range repos {
641+ values := valueMap(repo)
642+ names = append(names, plainString(values["identifier"])+" ("+plainString(values["level"])+")")
643+ }
644+ parts = append(parts, "repos: "+strings.Join(names, ", "))
645+ }
646+ if wiki := plainString(scope["wiki"]); wiki != "" {
647+ parts = append(parts, "wiki: "+wiki)
648+ }
649+ if kinds := scopeList(agent, "kinds"); kinds != "" {
650+ parts = append(parts, "kinds: "+kinds)
651+ }
652+ if len(parts) == 0 {
653+ return "profile and relay list only"
654+ }
655+ return strings.Join(parts, " | ")
656+}
657+
658+// agentCounts tallies agents by state for the panel.
659+func agentCounts(agents []any) map[string]int {
660+ counts := map[string]int{"active": 0, "paused": 0, "revoked": 0}
661+ for _, agent := range agents {
662+ counts[agentState(agent)]++
663+ }
664+ return counts
665+}
666+
667+// dateAfter is the date field value for a day count from today, in UTC.
668+func dateAfter(days int) string {
669+ return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02")
670+}
671+
672 var supportedMethods = []string{
673 "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",
674 }
675@@ -936,7 +1058,7 @@ func tabForPath(path string) string {
676 return "file"
677 case "signin", "sites":
678 return path
679- case "people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":
680+ case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":
681 return path
682 case "inbox", "outbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "terms":
683 return path
684diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
685index e73f8fd..85aa48f 100644
686--- a/internal/webui/webui_test.go
687+++ b/internal/webui/webui_test.go
688@@ -45,7 +45,7 @@ func TestPagesUsePlainHTMLAndExposeAllManagementTabs(t *testing.T) {
689 if err != nil {
690 t.Fatal(err)
691 }
692- for _, path := range []string{"/", "/inbox", "/outbox", "/manage/people", "/manage/moderation", "/manage/rules", "/manage/identity", "/manage/connect", "/manage/data", "/manage/sync", "/manage/views", "/manage/health", "/manage/owner"} {
693+ for _, path := range []string{"/", "/inbox", "/outbox", "/manage/people", "/manage/agents", "/manage/moderation", "/manage/rules", "/manage/identity", "/manage/connect", "/manage/data", "/manage/sync", "/manage/views", "/manage/health", "/manage/owner"} {
694 request := httptest.NewRequest(http.MethodGet, path, nil)
695 recorder := httptest.NewRecorder()
696 app.ServeHTTP(recorder, request)
697diff --git a/package.json b/package.json
698index e8ff5ef..1548abc 100644
699--- a/package.json
700+++ b/package.json
701@@ -8,7 +8,8 @@
702 "test:webmcp": "node --test scripts/webmcp.test.mjs",
703 "test:signer": "node --test scripts/signer.test.mjs",
704 "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs scripts/tiny-namespace.test.mjs",
705- "test:collaboration": "node --test scripts/collaboration-compose.test.mjs"
706+ "test:collaboration": "node --test scripts/collaboration-compose.test.mjs",
707+ "test:agents": "node --test scripts/agent-grant.test.mjs"
708 },
709 "devDependencies": {
710 "@noble/hashes": "2.4.0",
711diff --git a/scripts/agent-grant.test.mjs b/scripts/agent-grant.test.mjs
712new file mode 100644
713index 0000000..50f13b6
714--- /dev/null
715+++ b/scripts/agent-grant.test.mjs
716@@ -0,0 +1,133 @@
717+import test from "node:test";
718+import assert from "node:assert/strict";
719+import fs from "node:fs";
720+import vm from "node:vm";
721+import {finalizeEvent, generateSecretKey, getPublicKey, nip19, verifyEvent} from "nostr-tools";
722+
723+const owner = generateSecretKey();
724+const ownerPubkey = getPublicKey(owner);
725+const repoOwner = "b".repeat(64);
726+const source = fs.readFileSync("internal/webui/components.js", "utf8");
727+const tinySource = fs.readFileSync("internal/webui/tiny.js", "utf8");
728+const grantSource = source.slice(source.indexOf(" class AgentGrant "), source.indexOf(" class NostrKey "));
729+const today = new Date();
730+const dateAfter = days => new Date(today.getTime() + days * 86400000).toISOString().slice(0, 10);
731+
732+function setup(values = {}, options = {}) {
733+ const sent = [], signed = [], appended = [];
734+ let resets = 0, navigated = 0;
735+ class FormElement {
736+ getAttribute() { return null; }
737+ report(message) { this.message = message; }
738+ append(...nodes) { appended.push(...nodes); }
739+ }
740+ const el = (name, text) => ({name, text, children: [], attributes: {}, append(...nodes) { this.children.push(...nodes); }, setAttribute(key, value) { this.attributes[key] = value; }, addEventListener() {}});
741+ const tinySandbox = {globalThis: {}, crypto: {}, TextEncoder, URL};
742+ vm.runInNewContext(tinySource, tinySandbox);
743+ const window = {
744+ NostrSigner: {verifyEvent, generateSecretKey, getPublicKey},
745+ nostr: {signEvent: async event => {
746+ signed.push(structuredClone(event));
747+ if (options.change) event.tags.push(["k", "7"]);
748+ return finalizeEvent(structuredClone(event), options.signer || owner);
749+ }}
750+ };
751+ const tiny = {
752+ util: tinySandbox.globalThis.tiny.util,
753+ navigate: async () => { navigated++; },
754+ signedFetch: async (path, method, body) => {
755+ sent.push({path, method, event: JSON.parse(body)});
756+ return Response.json(options.result ?? {accepted: true});
757+ }
758+ };
759+ const Constructor = vm.runInNewContext(`${grantSource}\nAgentGrant`, {FormElement, window, tiny, el, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || ""), globalThis: {location: {pathname: "/manage/agents"}}});
760+ const component = new Constructor();
761+ 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++; }};
762+ return {Constructor, component, form, sent, signed, appended, resets: () => resets, navigated: () => navigated};
763+}
764+
765+test("a generated key yields a signed grant, an nsec shown once and no reload", async () => {
766+ const s = setup({rooms: "build, agents", repos: `${repoOwner}:tinyrelay:maintain\n${repoOwner}:docs:read`, kinds: "9, 1111, 1621", wiki: "propose", rate: "30"});
767+ await s.component.submit(s.form);
768+ assert.equal(s.sent.length, 1);
769+ const {event} = s.sent[0];
770+ assert.equal(s.sent[0].path, "/events");
771+ assert.equal(event.kind, 30392);
772+ assert.equal(event.pubkey, ownerPubkey);
773+ assert.equal(verifyEvent(event), true);
774+ const agent = event.tags[0][1];
775+ assert.match(agent, /^[0-9a-f]{64}$/);
776+ assert.notEqual(agent, ownerPubkey);
777+ const expiration = Number(event.tags.find(tag => tag[0] === "expiration")[1]);
778+ const now = Math.floor(Date.now() / 1000);
779+ assert.ok(expiration > now + 89 * 86400 && expiration <= now + 91 * 86400, "expiration is about 90 days out");
780+ assert.deepEqual(event.tags.filter(tag => tag[0] !== "expiration"), [
781+ ["d", agent], ["p", agent], ["name", "helper"],
782+ ["room", "build"], ["room", "agents"],
783+ ["repo", `${repoOwner}:tinyrelay:maintain`], ["repo", `${repoOwner}:docs:read`],
784+ ["k", "9"], ["k", "1111"], ["k", "1621"],
785+ ["wiki", "propose"], ["rate", "30"]
786+ ]);
787+ assert.equal(event.content, "");
788+ // The secret is shown once, in a read-only field, and matches the agent key.
789+ const secretInput = s.appended.flatMap(node => node.children || []).find(node => typeof node.value === "string" && node.value.startsWith("nsec1"));
790+ assert.ok(secretInput, "nsec field appended");
791+ assert.equal(secretInput.readOnly, true);
792+ const decoded = nip19.decode(secretInput.value);
793+ assert.equal(decoded.type, "nsec");
794+ assert.equal(getPublicKey(decoded.data), agent);
795+ assert.ok(s.appended.some(node => node.attributes?.role === "alert"), "warning shown");
796+ assert.equal(s.navigated(), 0);
797+ assert.equal(s.resets(), 0);
798+ assert.equal(s.component.message, "Granted.");
799+});
800+
801+test("a pasted public key signs the grant and reloads the page", async () => {
802+ const agent = "c".repeat(64);
803+ const s = setup({key: "paste", pubkey: " " + agent.toUpperCase() + " ", name: "", rate: ""});
804+ await s.component.submit(s.form);
805+ const {event} = s.sent[0];
806+ assert.deepEqual(event.tags.map(tag => tag[0]), ["d", "p", "expiration"]);
807+ assert.equal(event.tags[0][1], agent);
808+ assert.equal(s.appended.length, 0);
809+ assert.equal(s.navigated(), 1);
810+ assert.equal(s.resets(), 1);
811+});
812+
813+test("invalid fields are refused before anything is signed", async () => {
814+ for (const [values, message] of [
815+ [{key: "paste", pubkey: "nope"}, /public key/],
816+ [{name: "x".repeat(65)}, /64 characters/],
817+ [{expires: dateAfter(-1)}, /after today/],
818+ [{expires: dateAfter(400)}, /365 days/],
819+ [{repos: "tinyrelay:maintain"}, /Repositories are/],
820+ [{repos: `${repoOwner}:tinyrelay:admin`}, /Repositories are/],
821+ [{kinds: "1, abc"}, /Kinds are/],
822+ [{kinds: "70000"}, /Kinds are/],
823+ [{wiki: "delete"}, /propose or edit/],
824+ [{rate: "0"}, /1 to 600/],
825+ [{rate: "601"}, /1 to 600/]
826+ ]) {
827+ const s = setup(values);
828+ await assert.rejects(s.component.submit(s.form), message);
829+ assert.equal(s.signed.length, 0);
830+ assert.equal(s.sent.length, 0);
831+ }
832+});
833+
834+test("a changed event, a self grant and a relay refusal keep the form", async () => {
835+ const changed = setup({}, {change: true});
836+ await assert.rejects(changed.component.submit(changed.form), /invalid or changed/);
837+ assert.equal(changed.sent.length, 0);
838+ const refused = setup({}, {result: {accepted: false, message: "restricted: moderator required"}});
839+ await assert.rejects(refused.component.submit(refused.form), /moderator required/);
840+ assert.equal(refused.appended.length, 0);
841+ assert.equal(refused.resets(), 0);
842+});
843+
844+test("tags dedupe rooms, repositories and kinds", () => {
845+ const {Constructor} = setup();
846+ const agent = "d".repeat(64);
847+ 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);
848+ 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"]]);
849+});

About

ownerc01503e8035f
visibilitypublic
head1351f00ce719

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs