tinyrelaycommit

Commit a28df1f1e8f9

browse files at this commit | history

1diff --git a/docs/agents.md b/docs/agents.md
2index 62514ef..dd09012 100644
3--- a/docs/agents.md
4+++ b/docs/agents.md
5@@ -17,7 +17,7 @@ The owner or a moderator grants an agent by publishing a kind 30392 event addres
6 | `room` | No | A room the agent may post in. Repeat the tag for each room. |
7 | `repo` | No | `<owner pubkey>:<identifier>:<read or maintain>`. Repeat for each repository. |
8 | `k` | No | An event kind the agent may publish. Repeat for each kind. |
9-| `wiki` | No | `propose` or `edit`. Recorded for wiki tooling. |
10+| `wiki` | No | `propose` lets the agent publish wiki pages (kind 30818) and merge requests (kind 818); `edit` adds redirects (kind 30819). No `k` tags are needed for these. |
11 | `jobs` | No | `request`, `serve` or `both`. Lets the agent publish long task requests, answer them, or both. See [Long tasks](#long-tasks). |
12 | `rate` | No | Events per minute, 1 to 600. The default is 60. |
13
14@@ -53,7 +53,7 @@ A key that already has a human role keeps that role. A grant never lowers a memb
15 Every event from an agent key passes these checks before it is stored, whether it arrives from the agent directly or through synchronization with another relay:
16
17 - The grant is not paused, not revoked and not expired.
18-- The event kind appears in the grant's `k` tags, or the grant's `jobs` tag covers it. Profiles (kind 0) and relay lists (kind 10002) are always allowed.
19+- The event kind appears in the grant's `k` tags, or the grant's `wiki` or `jobs` tag covers it. Profiles (kind 0) and relay lists (kind 10002) are always allowed.
20 - A job result or job feedback names a request the relay holds and the agent may read, and matches that request's kind and author.
21 - If the event carries an `h` tag, the room appears in the grant's `room` tags.
22 - Repository events name a repository the grant covers. Issues, patches, pull requests and comments need `read` or `maintain`. Status changes (kinds 1630 to 1633) need `maintain`.
23@@ -87,7 +87,7 @@ Either way the agent loses its role at once. A fresh grant restores access.
24
25 ## Manage > Agents
26
27-The **Manage > Agents** page is where the owner and moderators see and control agents. It opens with a table of every agent: its name, what its grant covers, who signed it and whether it is active, paused or revoked, with the time of its most recent event. Each agent then has a card with the grant's facts: the agent's key (click it to copy), rooms, repositories with their access level (`read` or `maintain`), wiki access, kinds, rate, expiry and owner. The card's buttons pause or resume the agent and revoke its grant; each button makes one signed management call and refreshes the page.
28+The **Manage > Agents** page is where the owner and moderators see and control agents. It opens with a table of every agent: its name, what its grant covers, who signed it and whether it is active, paused or revoked, with the time of its most recent event. Each agent then has a card with the grant's facts: the agent's key (click it to copy), rooms, repositories with their access level (`read` or `maintain`), wiki access, kinds, rate, expiry and owner. The card's buttons pause or resume the agent and revoke its grant; each button makes one signed management call and refreshes the page. **Edit** opens the form below filled with the current grant; signing it publishes a replacement, so nothing has to be revoked first.
29
30 Below the cards, **Recent activity** lists the 10 newest events from one agent. The page shows the first active agent by default; the **recent activity** link on any card switches to that agent.
31
32diff --git a/internal/community/agents.go b/internal/community/agents.go
33index 23e9889..1b146e6 100644
34--- a/internal/community/agents.go
35+++ b/internal/community/agents.go
36@@ -103,6 +103,14 @@ func (g AgentGrant) AllowsKind(kind int) bool {
37 if (event.IsJobResult(kind) || kind == event.KIND_JOB_FEEDBACK) && g.ServesJobs() {
38 return true
39 }
40+ // Wiki access carries its kinds: proposing means publishing a version and
41+ // asking for a merge; editing adds redirects.
42+ switch g.Scope.Wiki {
43+ case "propose":
44+ return kind == event.KIND_WIKI_ARTICLE || kind == event.KIND_WIKI_MERGE
45+ case "edit":
46+ return kind == event.KIND_WIKI_ARTICLE || kind == event.KIND_WIKI_MERGE || kind == event.KIND_WIKI_REDIRECT
47+ }
48 return false
49 }
50
51diff --git a/internal/event/kinds.go b/internal/event/kinds.go
52index f8a5b51..67ad632 100644
53--- a/internal/event/kinds.go
54+++ b/internal/event/kinds.go
55@@ -56,7 +56,12 @@ const (
56 KIND_CONTENT_EDIT = 40003
57 KIND_ROOM_MEMBER_ADDED = 44100
58 KIND_ROOM_MEMBER_REMOVED = 44101
59- KIND_AGENT_GRANT = 30392
60+
61+ // NIP-54 wiki: articles, merge requests and redirects.
62+ KIND_WIKI_ARTICLE = 30818
63+ KIND_WIKI_MERGE = 818
64+ KIND_WIKI_REDIRECT = 30819
65+ KIND_AGENT_GRANT = 30392
66 )
67
68 // NIP-90 long tasks: a job request kind in 5000 to 5999 is answered by a
69diff --git a/internal/gates/agents_test.go b/internal/gates/agents_test.go
70index 69be806..ce68153 100644
71--- a/internal/gates/agents_test.go
72+++ b/internal/gates/agents_test.go
73@@ -131,6 +131,18 @@ func TestAgentGrantScopesKindsRoomsAndRepositories(t *testing.T) {
74 if err := f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow); err != nil {
75 t.Fatalf("maintainer status rejected: %v", err)
76 }
77+ // Wiki access carries the wiki kinds without listing them.
78+ f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"wiki", "propose"}))
79+ for _, kind := range []int{event.KIND_WIKI_ARTICLE, event.KIND_WIKI_MERGE} {
80+ if err := f.gate.Write(ctx, signed(t, agentSecret, kind, agentNow, [][]string{{"d", "notes"}, {"title", "Notes"}, {"a", "30818:" + f.owner + ":notes"}, {"p", f.owner}, {"e", strings.Repeat("e", 64), "", "source"}}, "wiki"), session, agentNow); err != nil {
81+ t.Fatalf("wiki kind %d rejected for propose: %v", kind, err)
82+ }
83+ }
84+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}}, ""), session, agentNow), "kind 30819")
85+ f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"wiki", "edit"}))
86+ if err := f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}, {"redirect", "30818:" + f.owner + ":notes"}}, ""), session, agentNow); err != nil {
87+ t.Fatalf("redirect rejected for edit: %v", err)
88+ }
89 }
90
91 func TestAgentGrantStateRejectsWrites(t *testing.T) {
92diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go
93index d7a5418..79d97a3 100644
94--- a/internal/webui/agents_test.go
95+++ b/internal/webui/agents_test.go
96@@ -218,3 +218,35 @@ func TestAgentStateHelpers(t *testing.T) {
97 t.Errorf("callback counts = %v", counts)
98 }
99 }
100+
101+func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
102+ backend := &agentsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
103+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ recorder := httptest.NewRecorder()
108+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentActive+"&edit="+agentActive, nil))
109+ body := recorder.Body.String()
110+ for _, marker := range []string{
111+ `<h3 id="grant">Replace the grant for hermes</h3>`,
112+ `<input name="name" maxlength="64" required placeholder="release-notes" value="hermes">`,
113+ `<option value="paste" selected>paste a public key</option>`,
114+ `<input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters" value="` + agentActive + `">`,
115+ `placeholder="build, agents" value="build, agents">`,
116+ `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain</textarea>`,
117+ `<option value="propose" selected>propose</option>`,
118+ `placeholder="1, 1111, 1621" value="9, 1111, 1621">`,
119+ `<button>Sign the replacement</button>`,
120+ `&amp;edit=` + agentActive + `#grant">edit</a>`,
121+ } {
122+ if !strings.Contains(body, marker) {
123+ t.Errorf("edit form missing %q", marker)
124+ }
125+ }
126+ recorder = httptest.NewRecorder()
127+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?edit=nonsense", nil))
128+ if body := recorder.Body.String(); !strings.Contains(body, `<h3 id="grant">New agent</h3>`) || strings.Contains(body, "selected>paste") {
129+ t.Fatal("unknown edit key did not fall back to the blank form")
130+ }
131+}
132diff --git a/internal/webui/manage.html b/internal/webui/manage.html
133index 1821581..165bf72 100644
134--- a/internal/webui/manage.html
135+++ b/internal/webui/manage.html
136@@ -52,7 +52,7 @@
137 {{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}}
138 <tr><th>callbacks</th><td>{{range $c := callbacksFor $.Callbacks $pk}}{{$cm := asMap $c}}{{$cs := callbackState $c}}{{$cid := str (index $cm "id")}}<agent-callback data-state="{{$cs}}"><code>{{str (index $cm "host")}}</code> kinds {{callbackKinds $c}} | <span data-state="{{$cs}}" title="{{str (index $cm "lastStatus")}}">{{$cs}}</span>{{with index $cm "lastDelivery"}}{{if when .}} | delivered <time datetime="{{datetime .}}">{{when .}}</time>{{end}}{{end}} {{if eq $cs "paused"}}<rpc-form method="resumecallback" refresh><input type="hidden" name="param" value="&quot;{{$cid}}&quot;"><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value="&quot;{{$cid}}&quot;"><button>Pause</button></rpc-form>{{end}}<rpc-form method="removecallback" refresh><input type="hidden" name="param" value="&quot;{{$cid}}&quot;"><button>Remove</button></rpc-form></agent-callback>{{else}}none{{end}}</td></tr>
139 </table>
140-<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>
141+<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>{{if ne $state "revoked"}} <a href="/manage/agents?agent={{$pk}}&amp;edit={{$pk}}#grant">edit</a>{{end}}</p>
142 </agent-card>
143 {{end}}{{with asMap .Event}}{{$agent := asMap (index . "agent")}}
144 <h3>Recent activity of {{agentLabel $agent}}</h3>
145@@ -61,19 +61,19 @@
146 {{end}}</ol>
147 {{else}}<p>No events from this agent yet.</p>
148 {{end}}{{end}}
149-<h3>New agent</h3>
150-<p>The grant is a kind 30392 event signed by your key. The agent's own key signs everything it publishes.</p>
151+{{$edit := agentByKey .Feed (.Query.Get "edit")}}{{$es := asMap (index (asMap $edit) "scope")}}<h3 id="grant">{{if $edit}}Replace the grant for {{agentLabel $edit}}{{else}}New agent{{end}}</h3>
152+<p>{{if $edit}}The form holds the current grant. Signing it publishes a new kind 30392 event for the same key, which replaces the old grant at once.{{else}}The grant is a kind 30392 event signed by your key. The agent's own key signs everything it publishes.{{end}}</p>
153 <agent-grant>
154-<label>Name <input name="name" maxlength="64" required placeholder="release-notes"></label>
155-<label>Key <select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select></label>
156-<label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"></label>
157-<label>Rooms <input name="rooms" placeholder="build, agents"></label>
158-<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain"></textarea></label>
159-<label>Wiki <select name="wiki"><option value="">none</option><option value="propose">propose</option><option value="edit">edit</option></select></label>
160-<label>Kinds <input name="kinds" placeholder="1, 1111, 1621"></label>
161-<label>Rate per minute <input name="rate" type="number" min="1" max="600" value="60"></label>
162-<label>Expires <input name="expires" type="date" value="{{dateAfter 90}}" required></label>
163-<button>Sign grant</button>
164+<label>Name <input name="name" maxlength="64" required placeholder="release-notes"{{with $edit}} value="{{agentLabel .}}"{{end}}></label>
165+<label>Key <select name="key"><option value="generate">generate here, show once</option><option value="paste"{{if $edit}} selected{{end}}>paste a public key</option></select></label>
166+<label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"{{with $edit}} value="{{str (index (asMap .) "pubkey")}}"{{end}}></label>
167+<label>Rooms <input name="rooms" placeholder="build, agents"{{with $edit}} value="{{scopeList . "rooms"}}"{{end}}></label>
168+<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label>
169+<label>Wiki <select name="wiki"><option value="">none</option><option value="propose"{{if eq (str (index $es "wiki")) "propose"}} selected{{end}}>propose</option><option value="edit"{{if eq (str (index $es "wiki")) "edit"}} selected{{end}}>edit</option></select></label>
170+<label>Kinds <input name="kinds" placeholder="1, 1111, 1621"{{with $edit}} value="{{scopeList . "kinds"}}"{{end}}></label>
171+<label>Rate per minute <input name="rate" type="number" min="1" max="600" value="{{if $edit}}{{str (index $es "rate")}}{{else}}60{{end}}"></label>
172+<label>Expires <input name="expires" type="date" value="{{if $edit}}{{dateOf (index (asMap $edit) "expires")}}{{else}}{{dateAfter 90}}{{end}}" required></label>
173+<button>{{if $edit}}Sign the replacement{{else}}Sign grant{{end}}</button>
174 <noscript><p>Signing a grant needs JavaScript and a connected signer. You can also publish the kind 30392 event from any client.</p></noscript>
175 </agent-grant>
176 </section>
177diff --git a/internal/webui/templates.go b/internal/webui/templates.go
178index eca25b1..40fd257 100644
179--- a/internal/webui/templates.go
180+++ b/internal/webui/templates.go
181@@ -264,6 +264,9 @@ func parseTemplates() (*template.Template, error) {
182 "callbackCounts": callbackCounts,
183 "scopeList": scopeList,
184 "dateAfter": dateAfter,
185+ "agentByKey": agentByKey,
186+ "repoLines": repoLines,
187+ "dateOf": dateOf,
188 "approvalViews": approvalViews,
189 "approvalCounts": approvalCounts,
190 "approvalDevices": approvalDevices,
191diff --git a/internal/webui/webui.go b/internal/webui/webui.go
192index 7cc649f..be9e8ae 100644
193--- a/internal/webui/webui.go
194+++ b/internal/webui/webui.go
195@@ -1085,6 +1085,41 @@ func dateAfter(days int) string {
196 return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02")
197 }
198
199+// agentByKey finds one grant in the agents list, for the form that replaces
200+// it. An unknown or empty key yields nil, so the form stays blank.
201+func agentByKey(agents []any, pubkey string) any {
202+ if !hexID.MatchString(pubkey) {
203+ return nil
204+ }
205+ for _, agent := range agents {
206+ if plainString(valueMap(agent)["pubkey"]) == pubkey {
207+ return agent
208+ }
209+ }
210+ return nil
211+}
212+
213+// repoLines renders a grant's repositories one per line, the way the form
214+// takes them: owner:identifier:level.
215+func repoLines(agent any) string {
216+ repos, _ := valueMap(valueMap(agent)["scope"])["repos"].([]any)
217+ lines := make([]string, 0, len(repos))
218+ for _, repo := range repos {
219+ r := valueMap(repo)
220+ lines = append(lines, plainString(r["owner"])+":"+plainString(r["identifier"])+":"+plainString(r["level"]))
221+ }
222+ return strings.Join(lines, "\n")
223+}
224+
225+// dateOf renders a unix time as the form's date value.
226+func dateOf(value any) string {
227+ seconds := unixSeconds(value)
228+ if seconds <= 0 {
229+ return ""
230+ }
231+ return time.Unix(seconds, 0).UTC().Format("2006-01-02")
232+}
233+
234 var supportedMethods = []string{
235 "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", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback",
236 }

About

ownerc01503e8035f
visibilitypublic
heada28df1f1e8f9

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs