| 1 | diff --git a/docs/agents.md b/docs/agents.md |
| 2 | index 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 | |
| 32 | diff --git a/internal/community/agents.go b/internal/community/agents.go |
| 33 | index 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 | |
| 51 | diff --git a/internal/event/kinds.go b/internal/event/kinds.go |
| 52 | index 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 |
| 69 | diff --git a/internal/gates/agents_test.go b/internal/gates/agents_test.go |
| 70 | index 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) { |
| 92 | diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go |
| 93 | index 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 | + `&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 | +} |
| 132 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 133 | index 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=""{{$cid}}""><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Pause</button></rpc-form>{{end}}<rpc-form method="removecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><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=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><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=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><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}}&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 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 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> |
| 177 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 178 | index 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, |
| 191 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 192 | index 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 | } |