clone

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

let wiki grants carry the wiki kinds and edit a grant from its card

Dami committed a28df1f
commit
a28df1f1e8f9bad7b6c66a893a248c01e1e4f128
parent
f8e54b2a8cfb72f50db1b39123e749ef079fee90
author
Dami <dami.etoile@gmail.com>
8 files changed, +112 -17
Mdocs/agents.md6+++---
Minternal/community/agents.go8+++++++
Minternal/event/kinds.go7+++++-
Minternal/gates/agents_test.go12++++++++++
Minternal/webui/agents_test.go32+++++++++++++++++++++++++++
Minternal/webui/manage.html26+++++++++++-----------
Minternal/webui/templates.go3+++
Minternal/webui/webui.go35++++++++++++++++++++++++++++++
M docs/agents.md +3 -3
@@ -17,7 +17,7 @@ The owner or a moderator grants an agent by publishing a kind 30392 event addres
1717 | `room` | No | A room the agent may post in. Repeat the tag for each room. |1818 | `repo` | No | `<owner pubkey>:<identifier>:<read or maintain>`. Repeat for each repository. |1919 | `k` | No | An event kind the agent may publish. Repeat for each kind. |20-| `wiki` | No | `propose` or `edit`. Recorded for wiki tooling. |20+| `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. |2121 | `jobs` | No | `request`, `serve` or `both`. Lets the agent publish long task requests, answer them, or both. See [Long tasks](#long-tasks). |2222 | `rate` | No | Events per minute, 1 to 600. The default is 60. |2323
@@ -53,7 +53,7 @@ A key that already has a human role keeps that role. A grant never lowers a memb
5353 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:5454 5555 - The grant is not paused, not revoked and not expired.56-- 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.56+- 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.5757 - 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.5858 - If the event carries an `h` tag, the room appears in the grant's `room` tags.5959 - 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`.
@@ -87,7 +87,7 @@ Either way the agent loses its role at once. A fresh grant restores access.
8787 8888 ## Manage > Agents8989 90-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.90+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.9191 9292 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.9393
M internal/community/agents.go +8 -0
M internal/event/kinds.go +6 -1
M internal/gates/agents_test.go +12 -0
M internal/webui/agents_test.go +32 -0
@@ -218,3 +218,35 @@ func TestAgentStateHelpers(t *testing.T) {
218218 t.Errorf("callback counts = %v", counts)219219 }220220 }221+222+func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {223+ backend := &agentsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}224+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})225+ if err != nil {226+ t.Fatal(err)227+ }228+ recorder := httptest.NewRecorder()229+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentActive+"&edit="+agentActive, nil))230+ body := recorder.Body.String()231+ for _, marker := range []string{232+ `<h3 id="grant">Replace the grant for hermes</h3>`,233+ `<input name="name" maxlength="64" required placeholder="release-notes" value="hermes">`,234+ `<option value="paste" selected>paste a public key</option>`,235+ `<input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters" value="` + agentActive + `">`,236+ `placeholder="build, agents" value="build, agents">`,237+ `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain</textarea>`,238+ `<option value="propose" selected>propose</option>`,239+ `placeholder="1, 1111, 1621" value="9, 1111, 1621">`,240+ `<button>Sign the replacement</button>`,241+ `&amp;edit=` + agentActive + `#grant">edit</a>`,242+ } {243+ if !strings.Contains(body, marker) {244+ t.Errorf("edit form missing %q", marker)245+ }246+ }247+ recorder = httptest.NewRecorder()248+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?edit=nonsense", nil))249+ if body := recorder.Body.String(); !strings.Contains(body, `<h3 id="grant">New agent</h3>`) || strings.Contains(body, "selected>paste") {250+ t.Fatal("unknown edit key did not fall back to the blank form")251+ }252+}
M internal/webui/manage.html +13 -13
@@ -52,7 +52,7 @@
5252 {{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}}5353 <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>5454 </table>55-<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+<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>5656 </agent-card>5757 {{end}}{{with asMap .Event}}{{$agent := asMap (index . "agent")}}5858 <h3>Recent activity of {{agentLabel $agent}}</h3>
@@ -61,19 +61,19 @@
6161 {{end}}</ol>6262 {{else}}<p>No events from this agent yet.</p>6363 {{end}}{{end}}64-<h3>New agent</h3>65-<p>The grant is a kind 30392 event signed by your key. The agent's own key signs everything it publishes.</p>64+{{$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>65+<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>6666 <agent-grant>67-<label>Name <input name="name" maxlength="64" required placeholder="release-notes"></label>68-<label>Key <select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select></label>69-<label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"></label>70-<label>Rooms <input name="rooms" placeholder="build, agents"></label>71-<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain"></textarea></label>72-<label>Wiki <select name="wiki"><option value="">none</option><option value="propose">propose</option><option value="edit">edit</option></select></label>73-<label>Kinds <input name="kinds" placeholder="1, 1111, 1621"></label>74-<label>Rate per minute <input name="rate" type="number" min="1" max="600" value="60"></label>75-<label>Expires <input name="expires" type="date" value="{{dateAfter 90}}" required></label>76-<button>Sign grant</button>67+<label>Name <input name="name" maxlength="64" required placeholder="release-notes"{{with $edit}} value="{{agentLabel .}}"{{end}}></label>68+<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>69+<label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"{{with $edit}} value="{{str (index (asMap .) "pubkey")}}"{{end}}></label>70+<label>Rooms <input name="rooms" placeholder="build, agents"{{with $edit}} value="{{scopeList . "rooms"}}"{{end}}></label>71+<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label>72+<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>73+<label>Kinds <input name="kinds" placeholder="1, 1111, 1621"{{with $edit}} value="{{scopeList . "kinds"}}"{{end}}></label>74+<label>Rate per minute <input name="rate" type="number" min="1" max="600" value="{{if $edit}}{{str (index $es "rate")}}{{else}}60{{end}}"></label>75+<label>Expires <input name="expires" type="date" value="{{if $edit}}{{dateOf (index (asMap $edit) "expires")}}{{else}}{{dateAfter 90}}{{end}}" required></label>76+<button>{{if $edit}}Sign the replacement{{else}}Sign grant{{end}}</button>7777 <noscript><p>Signing a grant needs JavaScript and a connected signer. You can also publish the kind 30392 event from any client.</p></noscript>7878 </agent-grant>7979 </section>
M internal/webui/templates.go +3 -0
M internal/webui/webui.go +35 -0
@@ -1085,6 +1085,41 @@ func dateAfter(days int) string {
10851085 return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02")10861086 }10871087 1088+// agentByKey finds one grant in the agents list, for the form that replaces1089+// it. An unknown or empty key yields nil, so the form stays blank.1090+func agentByKey(agents []any, pubkey string) any {1091+ if !hexID.MatchString(pubkey) {1092+ return nil1093+ }1094+ for _, agent := range agents {1095+ if plainString(valueMap(agent)["pubkey"]) == pubkey {1096+ return agent1097+ }1098+ }1099+ return nil1100+}1101+1102+// repoLines renders a grant's repositories one per line, the way the form1103+// takes them: owner:identifier:level.1104+func repoLines(agent any) string {1105+ repos, _ := valueMap(valueMap(agent)["scope"])["repos"].([]any)1106+ lines := make([]string, 0, len(repos))1107+ for _, repo := range repos {1108+ r := valueMap(repo)1109+ lines = append(lines, plainString(r["owner"])+":"+plainString(r["identifier"])+":"+plainString(r["level"]))1110+ }1111+ return strings.Join(lines, "\n")1112+}1113+1114+// dateOf renders a unix time as the form's date value.1115+func dateOf(value any) string {1116+ seconds := unixSeconds(value)1117+ if seconds <= 0 {1118+ return ""1119+ }1120+ return time.Unix(seconds, 0).UTC().Format("2006-01-02")1121+}1122+10881123 var supportedMethods = []string{10891124 "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",10901125 }