| 1 | diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go |
| 2 | index 47e5ad8..53b7ee4 100644 |
| 3 | --- a/internal/webui/agents_test.go |
| 4 | +++ b/internal/webui/agents_test.go |
| 5 | @@ -23,6 +23,7 @@ var ( |
| 6 | agentActive = strings.Repeat("1", 64) |
| 7 | agentPaused = strings.Repeat("2", 64) |
| 8 | agentRevoked = strings.Repeat("3", 64) |
| 9 | + agentSite = "npub1" + strings.Repeat("q", 58) |
| 10 | ) |
| 11 | |
| 12 | func (b *agentsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 13 | @@ -33,9 +34,10 @@ func (b *agentsBackend) Query(_ context.Context, method string, params []json.Ra |
| 14 | scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any { |
| 15 | return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60} |
| 16 | } |
| 17 | + hermes := scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621}) |
| 18 | + hermes["sites"] = []map[string]any{{"label": agentSite, "ttl": 30, "encrypted": true}, {"label": "*"}} |
| 19 | agents := []any{ |
| 20 | - map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120, |
| 21 | - "scope": scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})}, |
| 22 | + map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120, "scope": hermes}, |
| 23 | map[string]any{"pubkey": agentPaused, "owner": owner, "name": "reviewer-bot", "expires": now + 86400*30, "paused": true, "revoked": 0, "lastEvent": 0, |
| 24 | "scope": scope([]string{"build"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "read"}}, "", nil)}, |
| 25 | map[string]any{"pubkey": agentRevoked, "owner": strings.Repeat("c", 64), "name": "", "expires": now + 86400*30, "paused": false, "revoked": now - 86400, "lastEvent": now - 86400*2, |
| 26 | @@ -90,7 +92,10 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) { |
| 27 | `<a href="/manage/agents" aria-current="page">/agents</a>`, |
| 28 | `<table id="rows">`, |
| 29 | `<a href="#agent-` + agentActive + `">hermes</a>`, |
| 30 | - `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621`, |
| 31 | + `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`, |
| 32 | + `<tr><th>sites</th><td><code>` + agentSite + `</code> 30 days encrypted<br><code>*</code></td></tr>`, |
| 33 | + `<tr><th>sites</th><td>none</td></tr>`, |
| 34 | + `<textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7"></textarea>`, |
| 35 | `<td data-state="active">active | `, |
| 36 | `<td data-state="paused">paused</td>`, |
| 37 | `<td data-state="revoked">revoked | `, |
| 38 | @@ -236,6 +241,8 @@ func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) { |
| 39 | `placeholder="build, agents" value="build, agents">`, |
| 40 | `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain</textarea>`, |
| 41 | `<option value="propose" selected>propose</option>`, |
| 42 | + `<textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7">` + agentSite + ` ttl=30 encrypted |
| 43 | +*</textarea>`, |
| 44 | `placeholder="1, 1111, 1621" value="9, 1111, 1621">`, |
| 45 | `<button>Sign the replacement</button>`, |
| 46 | `&edit=` + agentActive + `#grant">edit</a>`, |
| 47 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 48 | index 61c2b71..b0ade96 100644 |
| 49 | --- a/internal/webui/components.js |
| 50 | +++ b/internal/webui/components.js |
| 51 | @@ -545,6 +545,24 @@ |
| 52 | const wiki = text("wiki"); |
| 53 | if (wiki && wiki !== "propose" && wiki !== "edit") throw Error("Wiki access is propose or edit."); |
| 54 | if (wiki) tags.push(["wiki", wiki]); |
| 55 | + // Sites: a label under the agent's key or *, then ttl=<days> and |
| 56 | + // encrypted, one entry per line. The relay checks the label's key. |
| 57 | + const seenSites = new Set(); |
| 58 | + for (const line of text("sites").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) { |
| 59 | + const [label, ...flags] = line.split(/\s+/); |
| 60 | + if (label !== "*" && !/^npub1[a-z0-9]{58}$/.test(label) && !/^[0-9a-z]{50}[a-z0-9-]{1,13}$/.test(label)) |
| 61 | + throw Error("Sites are a site label or *, then ttl=<days> and encrypted, one per line."); |
| 62 | + const tag = ["sites", label]; |
| 63 | + for (const flag of flags) { |
| 64 | + const ttl = /^ttl=(\d{1,3})$/.exec(flag); |
| 65 | + if (ttl && Number(ttl[1]) >= 1 && Number(ttl[1]) <= 365) tag.push("ttl=" + Number(ttl[1])); |
| 66 | + else if (flag === "encrypted") tag.push("encrypted"); |
| 67 | + else throw Error("Sites take ttl=<days> from 1 to 365 and encrypted after the label."); |
| 68 | + } |
| 69 | + if (seenSites.has(label)) continue; |
| 70 | + seenSites.add(label); |
| 71 | + tags.push(tag); |
| 72 | + } |
| 73 | const rate = text("rate"); |
| 74 | if (rate) { |
| 75 | if (!/^\d+$/.test(rate) || Number(rate) < 1 || Number(rate) > 600) throw Error("Rate is 1 to 600 events per minute."); |
| 76 | @@ -556,7 +574,7 @@ |
| 77 | async submit(form) { |
| 78 | if (!window.nostr?.signEvent) throw Error("Connect a signer first."); |
| 79 | const value = name => form.elements[name]?.value ?? ""; |
| 80 | - const values = {name: value("name"), expires: value("expires"), rooms: value("rooms"), repos: value("repos"), kinds: value("kinds"), wiki: value("wiki"), rate: value("rate")}; |
| 81 | + const values = {name: value("name"), expires: value("expires"), rooms: value("rooms"), repos: value("repos"), kinds: value("kinds"), wiki: value("wiki"), sites: value("sites"), rate: value("rate")}; |
| 82 | let secret = null, pubkey = value("pubkey").trim().toLowerCase(); |
| 83 | if (value("key") !== "paste") { |
| 84 | if (!window.NostrSigner?.generateSecretKey) throw Error("The signer bundle is still loading."); |
| 85 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 86 | index 90e2390..c06b009 100644 |
| 87 | --- a/internal/webui/manage.html |
| 88 | +++ b/internal/webui/manage.html |
| 89 | @@ -45,6 +45,7 @@ |
| 90 | <tr><th>rooms</th><td>{{or (scopeList $a "rooms") "none"}}</td></tr> |
| 91 | <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 "nostrName" (str (index $repo "owner"))}}{{else}}none{{end}}</td></tr> |
| 92 | <tr><th>wiki</th><td>{{or (str (index $scope "wiki")) "none"}}</td></tr> |
| 93 | +<tr><th>sites</th><td>{{range $i, $s := index $scope "sites"}}{{$site := asMap $s}}{{if $i}}<br>{{end}}<code>{{str (index $site "label")}}</code>{{with index $site "ttl"}} {{str .}} days{{end}}{{if index $site "encrypted"}} encrypted{{end}}{{else}}none{{end}}</td></tr> |
| 94 | <tr><th>kinds</th><td>{{or (scopeList $a "kinds") "profile and relay list only"}}</td></tr> |
| 95 | <tr><th>rate</th><td>{{str (index $scope "rate")}} events per minute</td></tr> |
| 96 | <tr><th>expires</th><td><time datetime="{{datetime (index $a "expires")}}">{{when (index $a "expires")}}</time></td></tr> |
| 97 | @@ -70,6 +71,7 @@ |
| 98 | <label>Rooms <input name="rooms" placeholder="build, agents"{{with $edit}} value="{{scopeList . "rooms"}}"{{end}}></label> |
| 99 | <label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label> |
| 100 | <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> |
| 101 | +<label>Sites <textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7">{{with $edit}}{{siteLines .}}{{end}}</textarea></label> |
| 102 | <label>Kinds <input name="kinds" placeholder="1, 1111, 1621"{{with $edit}} value="{{scopeList . "kinds"}}"{{end}}></label> |
| 103 | <label>Rate per minute <input name="rate" type="number" min="1" max="600" value="{{if $edit}}{{str (index $es "rate")}}{{else}}60{{end}}"></label> |
| 104 | <label>Expires <input name="expires" type="date" value="{{if $edit}}{{dateOf (index (asMap $edit) "expires")}}{{else}}{{dateAfter 90}}{{end}}" required></label> |
| 105 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 106 | index 5f9f499..f09ae87 100644 |
| 107 | --- a/internal/webui/templates.go |
| 108 | +++ b/internal/webui/templates.go |
| 109 | @@ -273,6 +273,7 @@ func parseTemplates() (*template.Template, error) { |
| 110 | "dateAfter": dateAfter, |
| 111 | "agentByKey": agentByKey, |
| 112 | "repoLines": repoLines, |
| 113 | + "siteLines": siteLines, |
| 114 | "dateOf": dateOf, |
| 115 | "approvalViews": approvalViews, |
| 116 | "approvalCounts": approvalCounts, |
| 117 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 118 | index bccde62..66678b4 100644 |
| 119 | --- a/internal/webui/webui.go |
| 120 | +++ b/internal/webui/webui.go |
| 121 | @@ -1036,6 +1036,13 @@ func agentScope(agent any) string { |
| 122 | if kinds := scopeList(agent, "kinds"); kinds != "" { |
| 123 | parts = append(parts, "kinds: "+kinds) |
| 124 | } |
| 125 | + if sites, _ := scope["sites"].([]any); len(sites) > 0 { |
| 126 | + labels := make([]string, 0, len(sites)) |
| 127 | + for _, site := range sites { |
| 128 | + labels = append(labels, plainString(valueMap(site)["label"])) |
| 129 | + } |
| 130 | + parts = append(parts, "sites: "+strings.Join(labels, ", ")) |
| 131 | + } |
| 132 | if len(parts) == 0 { |
| 133 | return "profile and relay list only" |
| 134 | } |
| 135 | @@ -1121,6 +1128,25 @@ func repoLines(agent any) string { |
| 136 | return strings.Join(lines, "\n") |
| 137 | } |
| 138 | |
| 139 | +// siteLines renders a grant's sites one per line, the way the form takes |
| 140 | +// them: the label, then ttl=<days> and encrypted when set. |
| 141 | +func siteLines(agent any) string { |
| 142 | + sites, _ := valueMap(valueMap(agent)["scope"])["sites"].([]any) |
| 143 | + lines := make([]string, 0, len(sites)) |
| 144 | + for _, site := range sites { |
| 145 | + s := valueMap(site) |
| 146 | + line := plainString(s["label"]) |
| 147 | + if ttl := plainString(s["ttl"]); ttl != "" && ttl != "0" { |
| 148 | + line += " ttl=" + ttl |
| 149 | + } |
| 150 | + if s["encrypted"] == true { |
| 151 | + line += " encrypted" |
| 152 | + } |
| 153 | + lines = append(lines, line) |
| 154 | + } |
| 155 | + return strings.Join(lines, "\n") |
| 156 | +} |
| 157 | + |
| 158 | // dateOf renders a unix time as the form's date value. |
| 159 | func dateOf(value any) string { |
| 160 | seconds := unixSeconds(value) |
| 161 | diff --git a/scripts/agent-grant.test.mjs b/scripts/agent-grant.test.mjs |
| 162 | index 50f13b6..6718d41 100644 |
| 163 | --- a/scripts/agent-grant.test.mjs |
| 164 | +++ b/scripts/agent-grant.test.mjs |
| 165 | @@ -42,7 +42,7 @@ function setup(values = {}, options = {}) { |
| 166 | }; |
| 167 | const Constructor = vm.runInNewContext(`${grantSource}\nAgentGrant`, {FormElement, window, tiny, el, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || ""), globalThis: {location: {pathname: "/manage/agents"}}}); |
| 168 | const component = new Constructor(); |
| 169 | - 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++; }}; |
| 170 | + const form = {elements: Object.fromEntries(Object.entries({key: "generate", name: "helper", expires: dateAfter(90), rooms: "", repos: "", kinds: "", wiki: "", sites: "", rate: "60", pubkey: "", ...values}).map(([key, value]) => [key, {value}])), reset: () => { resets++; }}; |
| 171 | return {Constructor, component, form, sent, signed, appended, resets: () => resets, navigated: () => navigated}; |
| 172 | } |
| 173 | |
| 174 | @@ -105,6 +105,10 @@ test("invalid fields are refused before anything is signed", async () => { |
| 175 | [{kinds: "1, abc"}, /Kinds are/], |
| 176 | [{kinds: "70000"}, /Kinds are/], |
| 177 | [{wiki: "delete"}, /propose or edit/], |
| 178 | + [{sites: "not-a-site"}, /Sites are/], |
| 179 | + [{sites: "* ttl=0"}, /1 to 365/], |
| 180 | + [{sites: "* ttl=366"}, /1 to 365/], |
| 181 | + [{sites: "* public"}, /1 to 365/], |
| 182 | [{rate: "0"}, /1 to 600/], |
| 183 | [{rate: "601"}, /1 to 600/] |
| 184 | ]) { |
| 185 | @@ -125,6 +129,22 @@ test("a changed event, a self grant and a relay refusal keep the form", async () |
| 186 | assert.equal(refused.resets(), 0); |
| 187 | }); |
| 188 | |
| 189 | +test("sites lines become sites tags with their ttl and encrypted flag", async () => { |
| 190 | + const npub = nip19.npubEncode("e".repeat(64)); |
| 191 | + const named = "0".repeat(50) + "docs"; |
| 192 | + const s = setup({sites: `${npub} ttl=30 encrypted\n ${named}\n* ttl=7\n${npub} ttl=1`}); |
| 193 | + await s.component.submit(s.form); |
| 194 | + const {event} = s.sent[0]; |
| 195 | + assert.deepEqual(event.tags.filter(tag => tag[0] === "sites"), [ |
| 196 | + ["sites", npub, "ttl=30", "encrypted"], |
| 197 | + ["sites", named], |
| 198 | + ["sites", "*", "ttl=7"] |
| 199 | + ]); |
| 200 | + const wiki = event.tags.findIndex(tag => tag[0] === "wiki"); |
| 201 | + const first = event.tags.findIndex(tag => tag[0] === "sites"); |
| 202 | + assert.ok(wiki === -1 || first > wiki, "sites follow the wiki tag"); |
| 203 | +}); |
| 204 | + |
| 205 | test("tags dedupe rooms, repositories and kinds", () => { |
| 206 | const {Constructor} = setup(); |
| 207 | const agent = "d".repeat(64); |