- commit
- 5fc70d5620a41205192bc8d37dd127dae8f81829
- parent
- 7456cee9607132d5b92f5bc67170c354ff808148
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 13 | ++++++++++--- |
| M | internal/ | 20 | +++++++++++++++++++- |
| M | internal/ | 2 | ++ |
| M | internal/ | 1 | + |
| M | internal/ | 26 | ++++++++++++++++++++++++++ |
| M | scripts/ | 22 | +++++++++++++++++++++- |
@@ -23,6 +23,7 @@ var (
2323 agentActive = strings.Repeat("1", 64)2424 agentPaused = strings.Repeat("2", 64)2525 agentRevoked = strings.Repeat("3", 64)26+ agentSite = "npub1" + strings.Repeat("q", 58)2627 )2728 2829 func (b *agentsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {@@ -33,9 +34,10 @@ func (b *agentsBackend) Query(_ context.Context, method string, params []json.Ra
3334 scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any {3435 return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60}3536 }37+ hermes := scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})38+ hermes["sites"] = []map[string]any{{"label": agentSite, "ttl": 30, "encrypted": true}, {"label": "*"}}3639 agents := []any{37- map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120,38- "scope": scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})},40+ map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120, "scope": hermes},3941 map[string]any{"pubkey": agentPaused, "owner": owner, "name": "reviewer-bot", "expires": now + 86400*30, "paused": true, "revoked": 0, "lastEvent": 0,4042 "scope": scope([]string{"build"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "read"}}, "", nil)},4143 map[string]any{"pubkey": agentRevoked, "owner": strings.Repeat("c", 64), "name": "", "expires": now + 86400*30, "paused": false, "revoked": now - 86400, "lastEvent": now - 86400*2,@@ -90,7 +92,10 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
9092 `<a href="/manage/agents" aria-current="page">/agents</a>`,9193 `<table id="rows">`,9294 `<a href="#agent-` + agentActive + `">hermes</a>`,93- `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621`,95+ `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`,96+ `<tr><th>sites</th><td><code>` + agentSite + `</code> 30 days encrypted<br><code>*</code></td></tr>`,97+ `<tr><th>sites</th><td>none</td></tr>`,98+ `<textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7"></textarea>`,9499 `<td data-state="active">active | `,95100 `<td data-state="paused">paused</td>`,96101 `<td data-state="revoked">revoked | `,@@ -236,6 +241,8 @@ func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
236241 `placeholder="build, agents" value="build, agents">`,237242 `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain</textarea>`,238243 `<option value="propose" selected>propose</option>`,244+ `<textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7">` + agentSite + ` ttl=30 encrypted245+*</textarea>`,239246 `placeholder="1, 1111, 1621" value="9, 1111, 1621">`,240247 `<button>Sign the replacement</button>`,241248 `&edit=` + agentActive + `#grant">edit</a>`,@@ -545,6 +545,24 @@
545545 const wiki = text("wiki");546546 if (wiki && wiki !== "propose" && wiki !== "edit") throw Error("Wiki access is propose or edit.");547547 if (wiki) tags.push(["wiki", wiki]);548+ // Sites: a label under the agent's key or *, then ttl=<days> and549+ // encrypted, one entry per line. The relay checks the label's key.550+ const seenSites = new Set();551+ for (const line of text("sites").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) {552+ const [label, ...flags] = line.split(/\s+/);553+ if (label !== "*" && !/^npub1[a-z0-9]{58}$/.test(label) && !/^[0-9a-z]{50}[a-z0-9-]{1,13}$/.test(label))554+ throw Error("Sites are a site label or *, then ttl=<days> and encrypted, one per line.");555+ const tag = ["sites", label];556+ for (const flag of flags) {557+ const ttl = /^ttl=(\d{1,3})$/.exec(flag);558+ if (ttl && Number(ttl[1]) >= 1 && Number(ttl[1]) <= 365) tag.push("ttl=" + Number(ttl[1]));559+ else if (flag === "encrypted") tag.push("encrypted");560+ else throw Error("Sites take ttl=<days> from 1 to 365 and encrypted after the label.");561+ }562+ if (seenSites.has(label)) continue;563+ seenSites.add(label);564+ tags.push(tag);565+ }548566 const rate = text("rate");549567 if (rate) {550568 if (!/^\d+$/.test(rate) || Number(rate) < 1 || Number(rate) > 600) throw Error("Rate is 1 to 600 events per minute.");@@ -556,7 +574,7 @@
556574 async submit(form) {557575 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");558576 const value = name => form.elements[name]?.value ?? "";559- const values = {name: value("name"), expires: value("expires"), rooms: value("rooms"), repos: value("repos"), kinds: value("kinds"), wiki: value("wiki"), rate: value("rate")};577+ 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")};560578 let secret = null, pubkey = value("pubkey").trim().toLowerCase();561579 if (value("key") !== "paste") {562580 if (!window.NostrSigner?.generateSecretKey) throw Error("The signer bundle is still loading.");@@ -45,6 +45,7 @@
4545 <tr><th>rooms</th><td>{{or (scopeList $a "rooms") "none"}}</td></tr>4646 <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>4747 <tr><th>wiki</th><td>{{or (str (index $scope "wiki")) "none"}}</td></tr>48+<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>4849 <tr><th>kinds</th><td>{{or (scopeList $a "kinds") "profile and relay list only"}}</td></tr>4950 <tr><th>rate</th><td>{{str (index $scope "rate")}} events per minute</td></tr>5051 <tr><th>expires</th><td><time datetime="{{datetime (index $a "expires")}}">{{when (index $a "expires")}}</time></td></tr>@@ -70,6 +71,7 @@
7071 <label>Rooms <input name="rooms" placeholder="build, agents"{{with $edit}} value="{{scopeList . "rooms"}}"{{end}}></label>7172 <label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label>7273 <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>74+<label>Sites <textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7">{{with $edit}}{{siteLines .}}{{end}}</textarea></label>7375 <label>Kinds <input name="kinds" placeholder="1, 1111, 1621"{{with $edit}} value="{{scopeList . "kinds"}}"{{end}}></label>7476 <label>Rate per minute <input name="rate" type="number" min="1" max="600" value="{{if $edit}}{{str (index $es "rate")}}{{else}}60{{end}}"></label>7577 <label>Expires <input name="expires" type="date" value="{{if $edit}}{{dateOf (index (asMap $edit) "expires")}}{{else}}{{dateAfter 90}}{{end}}" required></label>@@ -273,6 +273,7 @@ func parseTemplates() (*template.Template, error) {
273273 "dateAfter": dateAfter,274274 "agentByKey": agentByKey,275275 "repoLines": repoLines,276+ "siteLines": siteLines,276277 "dateOf": dateOf,277278 "approvalViews": approvalViews,278279 "approvalCounts": approvalCounts,@@ -1036,6 +1036,13 @@ func agentScope(agent any) string {
10361036 if kinds := scopeList(agent, "kinds"); kinds != "" {10371037 parts = append(parts, "kinds: "+kinds)10381038 }1039+ if sites, _ := scope["sites"].([]any); len(sites) > 0 {1040+ labels := make([]string, 0, len(sites))1041+ for _, site := range sites {1042+ labels = append(labels, plainString(valueMap(site)["label"]))1043+ }1044+ parts = append(parts, "sites: "+strings.Join(labels, ", "))1045+ }10391046 if len(parts) == 0 {10401047 return "profile and relay list only"10411048 }@@ -1121,6 +1128,25 @@ func repoLines(agent any) string {
11211128 return strings.Join(lines, "\n")11221129 }11231130 1131+// siteLines renders a grant's sites one per line, the way the form takes1132+// them: the label, then ttl=<days> and encrypted when set.1133+func siteLines(agent any) string {1134+ sites, _ := valueMap(valueMap(agent)["scope"])["sites"].([]any)1135+ lines := make([]string, 0, len(sites))1136+ for _, site := range sites {1137+ s := valueMap(site)1138+ line := plainString(s["label"])1139+ if ttl := plainString(s["ttl"]); ttl != "" && ttl != "0" {1140+ line += " ttl=" + ttl1141+ }1142+ if s["encrypted"] == true {1143+ line += " encrypted"1144+ }1145+ lines = append(lines, line)1146+ }1147+ return strings.Join(lines, "\n")1148+}1149+11241150 // dateOf renders a unix time as the form's date value.11251151 func dateOf(value any) string {11261152 seconds := unixSeconds(value)@@ -42,7 +42,7 @@ function setup(values = {}, options = {}) {
4242 };4343 const Constructor = vm.runInNewContext(`${grantSource}\nAgentGrant`, {FormElement, window, tiny, el, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || ""), globalThis: {location: {pathname: "/manage/agents"}}});4444 const component = new Constructor();45- 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++; }};45+ 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++; }};4646 return {Constructor, component, form, sent, signed, appended, resets: () => resets, navigated: () => navigated};4747 }4848 @@ -105,6 +105,10 @@ test("invalid fields are refused before anything is signed", async () => {
105105 [{kinds: "1, abc"}, /Kinds are/],106106 [{kinds: "70000"}, /Kinds are/],107107 [{wiki: "delete"}, /propose or edit/],108+ [{sites: "not-a-site"}, /Sites are/],109+ [{sites: "* ttl=0"}, /1 to 365/],110+ [{sites: "* ttl=366"}, /1 to 365/],111+ [{sites: "* public"}, /1 to 365/],108112 [{rate: "0"}, /1 to 600/],109113 [{rate: "601"}, /1 to 600/]110114 ]) {@@ -125,6 +129,22 @@ test("a changed event, a self grant and a relay refusal keep the form", async ()
125129 assert.equal(refused.resets(), 0);126130 });127131 132+test("sites lines become sites tags with their ttl and encrypted flag", async () => {133+ const npub = nip19.npubEncode("e".repeat(64));134+ const named = "0".repeat(50) + "docs";135+ const s = setup({sites: `${npub} ttl=30 encrypted\n ${named}\n* ttl=7\n${npub} ttl=1`});136+ await s.component.submit(s.form);137+ const {event} = s.sent[0];138+ assert.deepEqual(event.tags.filter(tag => tag[0] === "sites"), [139+ ["sites", npub, "ttl=30", "encrypted"],140+ ["sites", named],141+ ["sites", "*", "ttl=7"]142+ ]);143+ const wiki = event.tags.findIndex(tag => tag[0] === "wiki");144+ const first = event.tags.findIndex(tag => tag[0] === "sites");145+ assert.ok(wiki === -1 || first > wiki, "sites follow the wiki tag");146+});147+128148 test("tags dedupe rooms, repositories and kinds", () => {129149 const {Constructor} = setup();130150 const agent = "d".repeat(64);