clone

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

show and edit the sites scope on the Manage > Agents page

Dami committed 5fc70d5
commit
5fc70d5620a41205192bc8d37dd127dae8f81829
parent
7456cee9607132d5b92f5bc67170c354ff808148
author
Dami <dami.etoile@gmail.com>
6 files changed, +79 -5
Minternal/webui/agents_test.go13++++++++++---
Minternal/webui/components.js20+++++++++++++++++++-
Minternal/webui/manage.html2++
Minternal/webui/templates.go1+
Minternal/webui/webui.go26++++++++++++++++++++++++++
Mscripts/agent-grant.test.mjs22+++++++++++++++++++++-
M internal/webui/agents_test.go +10 -3
@@ -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&#10;* 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&#10;* ttl=7">` + agentSite + ` ttl=30 encrypted245+*</textarea>`,239246 `placeholder="1, 1111, 1621" value="9, 1111, 1621">`,240247 `<button>Sign the replacement</button>`,241248 `&amp;edit=` + agentActive + `#grant">edit</a>`,
M internal/webui/components.js +19 -1
@@ -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.");
M internal/webui/manage.html +2 -0
@@ -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&#10;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&#10;* 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>
M internal/webui/templates.go +1 -0
M internal/webui/webui.go +26 -0
M scripts/agent-grant.test.mjs +21 -1
@@ -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);