tinyrelaycommit

Commit 5fc70d5620a4

browse files at this commit | history

1diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go
2index 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&#10;* 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&#10;* ttl=7">` + agentSite + ` ttl=30 encrypted
43+*</textarea>`,
44 `placeholder="1, 1111, 1621" value="9, 1111, 1621">`,
45 `<button>Sign the replacement</button>`,
46 `&amp;edit=` + agentActive + `#grant">edit</a>`,
47diff --git a/internal/webui/components.js b/internal/webui/components.js
48index 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.");
85diff --git a/internal/webui/manage.html b/internal/webui/manage.html
86index 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&#10;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&#10;* 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>
105diff --git a/internal/webui/templates.go b/internal/webui/templates.go
106index 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,
117diff --git a/internal/webui/webui.go b/internal/webui/webui.go
118index 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)
161diff --git a/scripts/agent-grant.test.mjs b/scripts/agent-grant.test.mjs
162index 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);

About

ownerc01503e8035f
visibilitypublic
head5fc70d5620a4

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs