| 1 | diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go |
| 2 | new file mode 100644 |
| 3 | index 0000000..248124b |
| 4 | --- /dev/null |
| 5 | +++ b/internal/webui/agents_test.go |
| 6 | @@ -0,0 +1,192 @@ |
| 7 | +package webui |
| 8 | + |
| 9 | +import ( |
| 10 | + "context" |
| 11 | + "encoding/json" |
| 12 | + "net/http" |
| 13 | + "net/http/httptest" |
| 14 | + "strings" |
| 15 | + "testing" |
| 16 | + "time" |
| 17 | + |
| 18 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 19 | +) |
| 20 | + |
| 21 | +// agentsBackend answers listagents with three grants in every state and |
| 22 | +// browseagent with the selected grant plus two events. |
| 23 | +type agentsBackend struct { |
| 24 | + fakeBackend |
| 25 | + calls []string |
| 26 | +} |
| 27 | + |
| 28 | +var ( |
| 29 | + agentActive = strings.Repeat("1", 64) |
| 30 | + agentPaused = strings.Repeat("2", 64) |
| 31 | + agentRevoked = strings.Repeat("3", 64) |
| 32 | +) |
| 33 | + |
| 34 | +func (b *agentsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 35 | + b.calls = append(b.calls, method+":"+actor) |
| 36 | + owner := b.policy.Owner |
| 37 | + now := time.Now().Unix() |
| 38 | + scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any { |
| 39 | + return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60} |
| 40 | + } |
| 41 | + agents := []any{ |
| 42 | + map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120, |
| 43 | + "scope": scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})}, |
| 44 | + map[string]any{"pubkey": agentPaused, "owner": owner, "name": "reviewer-bot", "expires": now + 86400*30, "paused": true, "revoked": 0, "lastEvent": 0, |
| 45 | + "scope": scope([]string{"build"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "read"}}, "", nil)}, |
| 46 | + map[string]any{"pubkey": agentRevoked, "owner": strings.Repeat("c", 64), "name": "", "expires": now + 86400*30, "paused": false, "revoked": now - 86400, "lastEvent": now - 86400*2, |
| 47 | + "scope": scope([]string{"general"}, nil, "", []int{9})}, |
| 48 | + } |
| 49 | + switch method { |
| 50 | + case "listagents": |
| 51 | + return agents, nil |
| 52 | + case "browseagent": |
| 53 | + var query struct { |
| 54 | + Agent string `json:"agent"` |
| 55 | + } |
| 56 | + _ = json.Unmarshal(params[0], &query) |
| 57 | + for _, agent := range agents { |
| 58 | + if agent.(map[string]any)["pubkey"] == query.Agent { |
| 59 | + return map[string]any{"agent": agent, "events": []any{ |
| 60 | + map[string]any{"id": strings.Repeat("d", 64), "kind": 9, "pubkey": query.Agent, "created_at": now - 120, "content": "Draft is up as wiki: release-notes-1-4", "tags": [][]string{}}, |
| 61 | + map[string]any{"id": strings.Repeat("e", 64), "kind": 1111, "pubkey": query.Agent, "created_at": now - 3600, "content": "Reviewed PR 23: two nits, see inline.", "tags": [][]string{}}, |
| 62 | + }}, nil |
| 63 | + } |
| 64 | + } |
| 65 | + return nil, context.Canceled |
| 66 | + } |
| 67 | + return map[string]any{"method": method}, nil |
| 68 | +} |
| 69 | + |
| 70 | +func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) { |
| 71 | + backend := &agentsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 72 | + app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }}) |
| 73 | + if err != nil { |
| 74 | + t.Fatal(err) |
| 75 | + } |
| 76 | + recorder := httptest.NewRecorder() |
| 77 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents", nil)) |
| 78 | + body := recorder.Body.String() |
| 79 | + if recorder.Code != http.StatusOK { |
| 80 | + t.Fatalf("status=%d body=%s", recorder.Code, body) |
| 81 | + } |
| 82 | + if strings.Contains(body, "class=") { |
| 83 | + t.Fatal("agents page carries a class attribute") |
| 84 | + } |
| 85 | + if strings.Contains(body, "·") { |
| 86 | + t.Fatal("agents page contains a middle dot") |
| 87 | + } |
| 88 | + for _, want := range []string{ |
| 89 | + `<a href="/manage/agents" aria-current="page">/agents</a>`, |
| 90 | + `<table id="rows">`, |
| 91 | + `<a href="#agent-` + agentActive + `">hermes</a>`, |
| 92 | + `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621`, |
| 93 | + `<td data-state="active">active | `, |
| 94 | + `<td data-state="paused">paused</td>`, |
| 95 | + `<td data-state="revoked">revoked | `, |
| 96 | + `<a href="#agent-` + agentRevoked + `">` + shortID(agentRevoked) + `</a>`, |
| 97 | + `<agent-card id="agent-` + agentActive + `">`, |
| 98 | + `<h3>hermes <small><span data-state="active">active</span> | signed by you | expires `, |
| 99 | + `<code>tinyrelay</code> maintain, owner <nostr-key hex="` + backend.policy.Owner + `"`, |
| 100 | + `<th>rate</th><td>60 events per minute</td>`, |
| 101 | + `<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><button>Pause</button></rpc-form>`, |
| 102 | + `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><button>Revoke</button></rpc-form>`, |
| 103 | + `<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""` + agentPaused + `""><button>Resume</button></rpc-form>`, |
| 104 | + `<h3>Recent activity of hermes</h3>`, |
| 105 | + `<ol id="events">`, |
| 106 | + `<nostr-event id="event-` + strings.Repeat("d", 64) + `">`, |
| 107 | + `Reviewed PR 23: two nits, see inline.`, |
| 108 | + `<agent-grant>`, |
| 109 | + `<select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select>`, |
| 110 | + `<textarea name="repos"`, |
| 111 | + `<input name="expires" type="date" value="` + dateAfter(90) + `" required>`, |
| 112 | + `<h4>Agents</h4><table><tr><th>active</th><td>1</td></tr><tr><th>paused</th><td>1</td></tr><tr><th>revoked</th><td>1</td></tr></table>`, |
| 113 | + `<code>/mcp</code> 2026-07-28, planned`, |
| 114 | + `<rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form>`, |
| 115 | + `<rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form>`, |
| 116 | + } { |
| 117 | + if !strings.Contains(body, want) { |
| 118 | + t.Errorf("agents page missing %q", want) |
| 119 | + } |
| 120 | + } |
| 121 | + // The revoked agent keeps its facts but offers no controls. |
| 122 | + revokedCard := body[strings.Index(body, `<agent-card id="agent-`+agentRevoked+`">`):] |
| 123 | + revokedCard = revokedCard[:strings.Index(revokedCard, "</agent-card>")] |
| 124 | + if strings.Contains(revokedCard, "<rpc-form") || !strings.Contains(revokedCard, `<span data-state="revoked">revoked</span>`) { |
| 125 | + t.Errorf("revoked card should show state without controls: %s", revokedCard) |
| 126 | + } |
| 127 | + if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner { |
| 128 | + t.Fatalf("backend calls = %s", got) |
| 129 | + } |
| 130 | + // ?agent= selects whose activity shows. |
| 131 | + backend.calls = nil |
| 132 | + recorder = httptest.NewRecorder() |
| 133 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentPaused, nil)) |
| 134 | + if body := recorder.Body.String(); !strings.Contains(body, `<h3>Recent activity of reviewer-bot</h3>`) { |
| 135 | + t.Fatalf("selected agent activity missing: %s", body[:min(500, len(body))]) |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +type emptyAgentsBackend struct{ fakeBackend } |
| 140 | + |
| 141 | +func (b *emptyAgentsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 142 | + _, _ = b.fakeBackend.Query(ctx, method, params, actor) |
| 143 | + return []any{}, nil |
| 144 | +} |
| 145 | + |
| 146 | +func TestAgentsPageWithoutAgentsAndForGuests(t *testing.T) { |
| 147 | + backend := &emptyAgentsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 148 | + app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }}) |
| 149 | + if err != nil { |
| 150 | + t.Fatal(err) |
| 151 | + } |
| 152 | + request := httptest.NewRequest(http.MethodGet, "/manage/agents", nil) |
| 153 | + request.Header.Set("X-Actor", backend.policy.Owner) |
| 154 | + recorder := httptest.NewRecorder() |
| 155 | + app.ServeHTTP(recorder, request) |
| 156 | + body := recorder.Body.String() |
| 157 | + if recorder.Code != http.StatusOK || !strings.Contains(body, "No agents yet.") || !strings.Contains(body, "<agent-grant>") || strings.Contains(body, "<agent-card") { |
| 158 | + t.Fatalf("empty agents page: %d %s", recorder.Code, body[:min(600, len(body))]) |
| 159 | + } |
| 160 | + if backend.call != "listagents:"+backend.policy.Owner { |
| 161 | + t.Fatalf("empty list should not query an agent: %s", backend.call) |
| 162 | + } |
| 163 | + |
| 164 | + recorder = httptest.NewRecorder() |
| 165 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents", nil)) |
| 166 | + body = recorder.Body.String() |
| 167 | + if recorder.Code != http.StatusOK || !strings.Contains(body, "Sign in to manage this relay.") || strings.Contains(body, "<agent-grant>") || strings.Contains(body, "pauseallagents") { |
| 168 | + t.Fatalf("guest saw the agents page: %d %s", recorder.Code, body[:min(600, len(body))]) |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +func TestAgentStateHelpers(t *testing.T) { |
| 173 | + now := time.Now().Unix() |
| 174 | + for _, tc := range []struct { |
| 175 | + agent map[string]any |
| 176 | + state string |
| 177 | + since string |
| 178 | + }{ |
| 179 | + {map[string]any{"expires": float64(now + 3600), "lastEvent": float64(now - 60)}, "active", short(now - 60)}, |
| 180 | + {map[string]any{"expires": float64(now + 3600), "paused": true}, "paused", ""}, |
| 181 | + {map[string]any{"expires": float64(now + 3600), "revoked": float64(now - 10)}, "revoked", short(now - 10)}, |
| 182 | + {map[string]any{"expires": float64(now - 10), "paused": true}, "revoked", "expired " + short(now - 10)}, |
| 183 | + } { |
| 184 | + if got := agentState(tc.agent); got != tc.state { |
| 185 | + t.Errorf("agentState(%v) = %q, want %q", tc.agent, got, tc.state) |
| 186 | + } |
| 187 | + if got := agentSince(tc.agent); got != tc.since { |
| 188 | + t.Errorf("agentSince(%v) = %q, want %q", tc.agent, got, tc.since) |
| 189 | + } |
| 190 | + } |
| 191 | + if got := agentScope(map[string]any{"scope": map[string]any{}}); got != "profile and relay list only" { |
| 192 | + t.Errorf("empty scope = %q", got) |
| 193 | + } |
| 194 | + counts := agentCounts([]any{map[string]any{"expires": float64(now + 1)}, map[string]any{"expires": float64(now + 1), "paused": true}}) |
| 195 | + if counts["active"] != 1 || counts["paused"] != 1 || counts["revoked"] != 0 { |
| 196 | + t.Errorf("counts = %v", counts) |
| 197 | + } |
| 198 | +} |
| 199 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 200 | index 933661f..3709700 100644 |
| 201 | --- a/internal/webui/components.js |
| 202 | +++ b/internal/webui/components.js |
| 203 | @@ -2,9 +2,13 @@ |
| 204 | // light DOM with document APIs, takes plain lowercase attributes, and carries |
| 205 | // no styling of its own; page CSS targets the element name directly. |
| 206 | // |
| 207 | -// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params]> |
| 208 | +// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params] [refresh]> |
| 209 | // One signed NIP-86 management call. Children become the form body; the |
| 210 | // element appends an <output> status line and a <json-view> result. |
| 211 | +// With refresh, the page reloads in place after the call succeeds. |
| 212 | +// <agent-grant> |
| 213 | +// Signs a kind 30392 agent grant from its fields. Generates the agent |
| 214 | +// key here and shows the nsec once, or takes a pasted public key. |
| 215 | // <connect-list> |
| 216 | // The home page cards: lists, reorders, retargets and removes configured |
| 217 | // connections and adds from the catalog, saving with setconnections. |
| 218 | @@ -144,6 +148,10 @@ |
| 219 | this.report("Signing…"); |
| 220 | const result = await rpc(method, params, this.getAttribute("action")); |
| 221 | this.report("Done."); |
| 222 | + if (this.hasAttribute("refresh") && tiny.navigate) { |
| 223 | + await tiny.navigate(globalThis.location?.href); |
| 224 | + return; |
| 225 | + } |
| 226 | this.show(result); |
| 227 | } |
| 228 | |
| 229 | @@ -383,6 +391,122 @@ |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | + // AgentGrant signs a kind 30392 agent grant: the agent's key, name, scope |
| 234 | + // and expiry, signed by the owner or a moderator. The agent key is made |
| 235 | + // here and its nsec shown once, or pasted as a public key. The secret never |
| 236 | + // leaves the page; the relay only receives the signed grant. |
| 237 | + class AgentGrant extends FormElement { |
| 238 | + connectedCallback() { |
| 239 | + super.connectedCallback(); |
| 240 | + if (this.toggled || !this.form) return; |
| 241 | + this.toggled = true; |
| 242 | + const mode = this.form.elements.key; |
| 243 | + const toggle = () => { const label = this.form.elements.pubkey?.closest("label"); if (label) label.hidden = mode.value !== "paste"; }; |
| 244 | + mode?.addEventListener("change", toggle); |
| 245 | + if (mode) toggle(); |
| 246 | + } |
| 247 | + |
| 248 | + // tags builds the grant tags from the field values; it throws on any |
| 249 | + // value the relay would refuse so nothing reaches the signer. |
| 250 | + static tags(values, pubkey, now = Math.floor(Date.now() / 1000)) { |
| 251 | + const text = name => String(values[name] ?? "").trim(); |
| 252 | + const split = name => text(name).split(/[\s,]+/).map(item => item.trim()).filter(Boolean); |
| 253 | + if (!isHex64(pubkey)) throw Error("Enter the agent's public key as 64 lowercase hex characters."); |
| 254 | + const name = text("name"); |
| 255 | + if (name.length > 64) throw Error("Keep the name to 64 characters."); |
| 256 | + const tags = [["d", pubkey], ["p", pubkey]]; |
| 257 | + if (name) tags.push(["name", name]); |
| 258 | + const expires = Math.floor(Date.parse(text("expires") + "T23:59:59Z") / 1000); |
| 259 | + if (!Number.isFinite(expires) || expires <= now) throw Error("Choose an expiry date after today."); |
| 260 | + if (expires > now + 365 * 86400) throw Error("Grants last at most 365 days."); |
| 261 | + tags.push(["expiration", String(expires)]); |
| 262 | + for (const room of [...new Set(split("rooms"))]) { |
| 263 | + if (room.length > 128) throw Error("Room names are at most 128 characters."); |
| 264 | + tags.push(["room", room]); |
| 265 | + } |
| 266 | + const seenRepos = new Set(); |
| 267 | + for (const line of text("repos").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) { |
| 268 | + const match = /^([0-9a-f]{64}):(.+):(read|maintain)$/.exec(line); |
| 269 | + if (!match || match[2].length > 256) throw Error("Repositories are owner pubkey:identifier:read or maintain, one per line."); |
| 270 | + if (seenRepos.has(match[1] + ":" + match[2])) continue; |
| 271 | + seenRepos.add(match[1] + ":" + match[2]); |
| 272 | + tags.push(["repo", line]); |
| 273 | + } |
| 274 | + for (const kind of [...new Set(split("kinds"))]) { |
| 275 | + if (!/^\d{1,5}$/.test(kind) || Number(kind) > 65535) throw Error("Kinds are whole numbers from 0 to 65535."); |
| 276 | + tags.push(["k", String(Number(kind))]); |
| 277 | + } |
| 278 | + const wiki = text("wiki"); |
| 279 | + if (wiki && wiki !== "propose" && wiki !== "edit") throw Error("Wiki access is propose or edit."); |
| 280 | + if (wiki) tags.push(["wiki", wiki]); |
| 281 | + const rate = text("rate"); |
| 282 | + if (rate) { |
| 283 | + if (!/^\d+$/.test(rate) || Number(rate) < 1 || Number(rate) > 600) throw Error("Rate is 1 to 600 events per minute."); |
| 284 | + tags.push(["rate", String(Number(rate))]); |
| 285 | + } |
| 286 | + return tags; |
| 287 | + } |
| 288 | + |
| 289 | + async submit(form) { |
| 290 | + if (!window.nostr?.signEvent) throw Error("Connect a signer first."); |
| 291 | + const value = name => form.elements[name]?.value ?? ""; |
| 292 | + const values = {name: value("name"), expires: value("expires"), rooms: value("rooms"), repos: value("repos"), kinds: value("kinds"), wiki: value("wiki"), rate: value("rate")}; |
| 293 | + let secret = null, pubkey = value("pubkey").trim().toLowerCase(); |
| 294 | + if (value("key") !== "paste") { |
| 295 | + if (!window.NostrSigner?.generateSecretKey) throw Error("The signer bundle is still loading."); |
| 296 | + secret = window.NostrSigner.generateSecretKey(); |
| 297 | + pubkey = window.NostrSigner.getPublicKey(secret); |
| 298 | + } |
| 299 | + const tags = AgentGrant.tags(values, pubkey); |
| 300 | + const unsigned = {kind: 30392, created_at: Math.floor(Date.now() / 1000), tags, content: ""}; |
| 301 | + // Keep a separate copy: extensions may mutate their input while signing. |
| 302 | + const expected = JSON.stringify(unsigned); |
| 303 | + this.report("Signing…"); |
| 304 | + const event = await window.nostr.signEvent(JSON.parse(expected)); |
| 305 | + const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content}); |
| 306 | + if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event."); |
| 307 | + if (event.pubkey === pubkey) throw Error("The agent needs its own key, not yours."); |
| 308 | + const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"}); |
| 309 | + const result = await response.json(); |
| 310 | + if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the grant."); |
| 311 | + this.report("Granted."); |
| 312 | + if (secret) { |
| 313 | + this.reveal(pubkey, secret); |
| 314 | + return; |
| 315 | + } |
| 316 | + form.reset(); |
| 317 | + await tiny.navigate?.(globalThis.location?.href); |
| 318 | + } |
| 319 | + |
| 320 | + // reveal shows the generated secret once. The page is not reloaded so |
| 321 | + // it stays on screen until the person leaves. |
| 322 | + reveal(pubkey, secret) { |
| 323 | + const nsec = tiny.util.bech32Encode("nsec", secret); |
| 324 | + const warning = el("p", "Copy the agent's secret key now. It is not stored anywhere and will not be shown again."); |
| 325 | + warning.setAttribute("role", "alert"); |
| 326 | + const secretLabel = el("label", "Agent secret key "); |
| 327 | + secretLabel.setAttribute("data-secret", ""); |
| 328 | + const secretInput = el("input"); |
| 329 | + secretInput.readOnly = true; |
| 330 | + secretInput.name = "nsec"; |
| 331 | + secretInput.value = nsec; |
| 332 | + secretInput.addEventListener("focus", () => secretInput.select()); |
| 333 | + secretLabel.append(secretInput); |
| 334 | + const keyLabel = el("label", "Agent public key "); |
| 335 | + keyLabel.setAttribute("data-secret", ""); |
| 336 | + const keyInput = el("input"); |
| 337 | + keyInput.readOnly = true; |
| 338 | + keyInput.name = "agent"; |
| 339 | + keyInput.value = pubkey; |
| 340 | + keyLabel.append(keyInput); |
| 341 | + const link = el("a", "Show the new agent"); |
| 342 | + link.href = (globalThis.location?.pathname || "") + "?agent=" + pubkey; |
| 343 | + const note = el("p"); |
| 344 | + note.append(link); |
| 345 | + this.append(warning, secretLabel, keyLabel, note); |
| 346 | + } |
| 347 | + } |
| 348 | + |
| 349 | class NostrKey extends HTMLElement { |
| 350 | static get observedAttributes() { return ["hex"]; } |
| 351 | |
| 352 | @@ -872,6 +996,7 @@ |
| 353 | customElements.define("file-tools", FileTools); |
| 354 | customElements.define("publish-list", PublishList); |
| 355 | customElements.define("nostr-compose", NostrCompose); |
| 356 | + customElements.define("agent-grant", AgentGrant); |
| 357 | customElements.define("push-toggle", PushToggle); |
| 358 | customElements.define("share-link", ShareLink); |
| 359 | customElements.define("nostr-key", NostrKey); |
| 360 | @@ -902,7 +1027,7 @@ |
| 361 | if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null; |
| 362 | return root ? url.pathname.slice(root.length) || "/" : url.pathname; |
| 363 | }; |
| 364 | - const allowedRoute = path => /^(?:\/(?:inbox|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || ""); |
| 365 | + const allowedRoute = path => /^(?:\/(?:inbox|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || ""); |
| 366 | let navigationSerial = 0, activeAbort; |
| 367 | const streams = new Set(); |
| 368 | const closeStreams = () => { |
| 369 | @@ -917,7 +1042,7 @@ |
| 370 | document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg)); |
| 371 | window.addEventListener("pagehide", closeStreams); |
| 372 | const repairComponents = () => { |
| 373 | - document.querySelectorAll("rpc-form,signed-form,publish-list").forEach(node => { |
| 374 | + document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant").forEach(node => { |
| 375 | if (node.form?.isConnected) return; |
| 376 | node.form = null; |
| 377 | node.output = null; |
| 378 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 379 | index 80ba1d8..92f9d41 100644 |
| 380 | --- a/internal/webui/manage.html |
| 381 | +++ b/internal/webui/manage.html |
| 382 | @@ -29,6 +29,55 @@ |
| 383 | </section> |
| 384 | {{end}} |
| 385 | |
| 386 | +{{define "agents"}}<section> |
| 387 | +<h1>Agents</h1> |
| 388 | +<p>Agents are members with their own keys. Each one acts under a grant you sign, and stays the author of everything it does.</p> |
| 389 | +{{template "alert" .Error}} |
| 390 | +<table id="rows"><thead><tr><th>agent</th><th>scope</th><th>owner</th><th>state</th></tr></thead><tbody> |
| 391 | +{{range .Feed}}{{$a := asMap .}}{{$pk := str (index $a "pubkey")}}<tr><td><a href="#agent-{{$pk}}">{{agentLabel $a}}</a></td><td title="{{agentScope $a}}">{{agentScope $a}}</td><td>{{template "nostrKey" (str (index $a "owner"))}}</td><td data-state="{{agentState $a}}">{{agentState $a}}{{with agentSince $a}} | {{.}}{{end}}</td></tr> |
| 392 | +{{else}}<tr><td colspan="4">No agents yet. Sign a grant below to add one.</td></tr> |
| 393 | +{{end}}</tbody></table> |
| 394 | +{{range .Feed}}{{$a := asMap .}}{{$pk := str (index $a "pubkey")}}{{$state := agentState $a}}{{$scope := asMap (index $a "scope")}} |
| 395 | +<agent-card id="agent-{{$pk}}"> |
| 396 | +<h3>{{agentLabel $a}} <small><span data-state="{{$state}}">{{$state}}</span> | signed by {{if eq (str (index $a "owner")) $.Actor}}you{{else}}{{template "nostrKey" (str (index $a "owner"))}}{{end}} | expires {{short (index $a "expires")}}</small></h3> |
| 397 | +<table> |
| 398 | +<tr><th>key</th><td>{{template "nostrKey" $pk}} <code>{{$pk}}</code></td></tr> |
| 399 | +<tr><th>rooms</th><td>{{or (scopeList $a "rooms") "none"}}</td></tr> |
| 400 | +<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 "nostrKey" (str (index $repo "owner"))}}{{else}}none{{end}}</td></tr> |
| 401 | +<tr><th>wiki</th><td>{{or (str (index $scope "wiki")) "none"}}</td></tr> |
| 402 | +<tr><th>kinds</th><td>{{or (scopeList $a "kinds") "profile and relay list only"}}</td></tr> |
| 403 | +<tr><th>rate</th><td>{{str (index $scope "rate")}} events per minute</td></tr> |
| 404 | +<tr><th>expires</th><td><time datetime="{{datetime (index $a "expires")}}">{{when (index $a "expires")}}</time></td></tr> |
| 405 | +<tr><th>owner</th><td>{{template "nostrKey" (str (index $a "owner"))}}</td></tr> |
| 406 | +{{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}} |
| 407 | +</table> |
| 408 | +<p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Revoke</button></rpc-form>{{end}}<a href="/manage/agents?agent={{$pk}}">recent activity</a></p> |
| 409 | +</agent-card> |
| 410 | +{{end}}{{with asMap .Event}}{{$agent := asMap (index . "agent")}} |
| 411 | +<h3>Recent activity of {{agentLabel $agent}}</h3> |
| 412 | +{{$events := index . "events"}}{{if $events}}<ol id="events"> |
| 413 | +{{range $events}}<li>{{template "nostrEvent" .}}</li> |
| 414 | +{{end}}</ol> |
| 415 | +{{else}}<p>No events from this agent yet.</p> |
| 416 | +{{end}}{{end}} |
| 417 | +<h3>New agent</h3> |
| 418 | +<p>The grant is a kind 30392 event signed by your key. The agent's own key signs everything it publishes.</p> |
| 419 | +<agent-grant> |
| 420 | +<label>Name <input name="name" maxlength="64" required placeholder="release-notes"></label> |
| 421 | +<label>Key <select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select></label> |
| 422 | +<label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"></label> |
| 423 | +<label>Rooms <input name="rooms" placeholder="build, agents"></label> |
| 424 | +<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read owner pubkey:identifier:maintain"></textarea></label> |
| 425 | +<label>Wiki <select name="wiki"><option value="">none</option><option value="propose">propose</option><option value="edit">edit</option></select></label> |
| 426 | +<label>Kinds <input name="kinds" placeholder="1, 1111, 1621"></label> |
| 427 | +<label>Rate per minute <input name="rate" type="number" min="1" max="600" value="60"></label> |
| 428 | +<label>Expires <input name="expires" type="date" value="{{dateAfter 90}}" required></label> |
| 429 | +<button>Sign grant</button> |
| 430 | +<noscript><p>Signing a grant needs JavaScript and a connected signer. You can also publish the kind 30392 event from any client.</p></noscript> |
| 431 | +</agent-grant> |
| 432 | +</section> |
| 433 | +{{end}} |
| 434 | + |
| 435 | {{define "moderation"}}<section> |
| 436 | <h1>Moderation</h1> |
| 437 | <h3>Reports</h3> |
| 438 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 439 | index 6945843..25eab53 100644 |
| 440 | --- a/internal/webui/page.html |
| 441 | +++ b/internal/webui/page.html |
| 442 | @@ -38,6 +38,7 @@ |
| 443 | {{else if eq .Tab "grasp"}}{{template "grasp" .}} |
| 444 | {{else if eq .Tab "terms"}}{{template "terms" .}} |
| 445 | {{else if eq .Tab "people"}}{{template "people" .}} |
| 446 | +{{else if eq .Tab "agents"}}{{template "agents" .}} |
| 447 | {{else if eq .Tab "moderation"}}{{template "moderation" .}} |
| 448 | {{else if eq .Tab "rules"}}{{template "rules" .}} |
| 449 | {{else if eq .Tab "identity"}}{{template "identity" .}} |
| 450 | @@ -88,6 +89,7 @@ |
| 451 | {{/* The panel holds facts and filters for the current page. */}} |
| 452 | {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}} |
| 453 | {{else if eq .Tab "repo"}}{{template "panel-repo" .}} |
| 454 | +{{else if eq .Tab "agents"}}{{template "panel-agents" .}} |
| 455 | {{else if eq $kind "manage"}}{{template "panel-manage" .}} |
| 456 | {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}} |
| 457 | {{else if eq .Tab "repos"}}{{template "panel-repos" .}} |
| 458 | @@ -121,6 +123,11 @@ |
| 459 | <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section> |
| 460 | <section><h4>Result</h4><p><small>Each form shows its result below the form. Every call is signed by your connected signer.</small></p></section>{{end}} |
| 461 | |
| 462 | +{{define "panel-agents"}}{{$counts := agentCounts .Feed}}<section><h4>Agents</h4><table><tr><th>active</th><td>{{index $counts "active"}}</td></tr><tr><th>paused</th><td>{{index $counts "paused"}}</td></tr><tr><th>revoked</th><td>{{index $counts "revoked"}}</td></tr></table></section> |
| 463 | +<section><h4>Machine access</h4><table><tr><th>mcp</th><td><code>/mcp</code> 2026-07-28, planned</td></tr><tr><th>auth</th><td>NIP-98 per request</td></tr><tr><th>rest</th><td><code>/events</code> <code>/query</code></td></tr></table></section> |
| 464 | +<section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Pausing keeps grants and rejects writes until resumed.</small></p></section> |
| 465 | +<section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>{{end}} |
| 466 | + |
| 467 | {{/* Shared partials. */}} |
| 468 | |
| 469 | {{define "alert"}}{{if .}}<p role="alert">{{.}}</p>{{end}}{{end}} |
| 470 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 471 | index f2ce18a..7325244 100644 |
| 472 | --- a/internal/webui/shell_test.go |
| 473 | +++ b/internal/webui/shell_test.go |
| 474 | @@ -299,7 +299,7 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) { |
| 475 | if err != nil { |
| 476 | t.Fatal(err) |
| 477 | } |
| 478 | - for _, path := range []string{"/manage/people", "/manage/owner", "/manage/health", "/manage/data"} { |
| 479 | + for _, path := range []string{"/manage/people", "/manage/agents", "/manage/owner", "/manage/health", "/manage/data"} { |
| 480 | recorder := httptest.NewRecorder() |
| 481 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) |
| 482 | body := recorder.Body.String() |
| 483 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 484 | index f6ef063..0a46694 100644 |
| 485 | --- a/internal/webui/style.css |
| 486 | +++ b/internal/webui/style.css |
| 487 | @@ -134,6 +134,21 @@ file-workspace-root button{background:transparent;border:0;padding:0;color:var(- |
| 488 | connect-list{display:block}connect-list #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}connect-list #rows td:first-child{width:150px}connect-list #rows td:nth-child(2){color:var(--muted)}connect-list #rows td:nth-child(2) b{color:var(--ink);font-weight:normal}connect-list #rows td:nth-child(3){width:110px}connect-list #rows td:last-child{width:190px;white-space:nowrap}connect-list button,connect-list select{font-size:12px}connect-list output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px}connect-list output[data-error]{color:var(--alert)} |
| 489 | relay-lists{display:block}relay-lists #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}relay-lists #rows td:last-child{width:230px;text-align:left;color:var(--ink);white-space:nowrap}relay-lists #rows td:first-child{width:170px;color:var(--ink);font-family:var(--sans)}relay-lists output{display:block;font:12px var(--mono);color:var(--muted);white-space:normal}relay-lists button{font-size:12px} |
| 490 | |
| 491 | +/* agent-card: one grant's facts and controls. agent-grant signs a new one. */ |
| 492 | +agent-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px} |
| 493 | +agent-card h3{margin:0 0 4px;border:0;font-size:15px}agent-card h3 small{font:12px var(--mono);color:var(--muted)} |
| 494 | +agent-card table{border-collapse:collapse;font:13px var(--mono);width:auto;margin:0} |
| 495 | +agent-card th{text-align:left;font-weight:normal;color:var(--muted);width:9em;padding:3px 0;border:0;vertical-align:top}agent-card td{padding:3px 0;border:0;overflow-wrap:anywhere} |
| 496 | +agent-card td code{color:var(--muted)} |
| 497 | +agent-card p{margin:8px 0 0}agent-card rpc-form{display:inline-block;border:0;padding:0;margin:0 8px 0 0;vertical-align:top}agent-card rpc-form output:empty{display:none} |
| 498 | +agent-card p>a{font:12px var(--mono)} |
| 499 | +[data-state=active],#rows td[data-state=active]{color:var(--accent)}[data-state=paused],#rows td[data-state=paused]{color:var(--bark)}[data-state=revoked],#rows td[data-state=revoked]{color:var(--alert)} |
| 500 | +agent-grant{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px} |
| 501 | +agent-grant label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)} |
| 502 | +agent-grant label input,agent-grant label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px} |
| 503 | +agent-grant label:has(textarea),agent-grant label[data-secret]{display:block}agent-grant label[data-secret] input{width:100%}agent-grant label[hidden]{display:none} |
| 504 | +agent-grant output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}agent-grant output[data-error]{color:var(--alert)} |
| 505 | +#panel rpc-form{border:0;padding:4px 0}#panel rpc-form output:empty{display:none} |
| 506 | connect-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}connect-card h3{margin:0 0 4px;border:0;font-size:15px}connect-card h3 small{font:12px var(--mono);color:var(--muted)}connect-card p{margin:0 0 6px;color:var(--muted)}connect-card label{display:inline-block;font:13px var(--sans);color:var(--muted);margin:0 10px 6px 0}connect-card label input{display:block;font:13px var(--mono);min-width:200px}connect-card a,connect-card button{margin-right:10px;font-size:13px}connect-card output{display:block;font:12px var(--mono);color:var(--muted);overflow-wrap:anywhere}connect-card details img{display:block;margin:6px 0;background:#fff;padding:6px;border:1px solid var(--line)} |
| 507 | |
| 508 | /* json-view: structured rendering of any JSON payload. */ |
| 509 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 510 | index fd6dee0..0c6abcc 100644 |
| 511 | --- a/internal/webui/templates.go |
| 512 | +++ b/internal/webui/templates.go |
| 513 | @@ -106,7 +106,7 @@ var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "sear |
| 514 | |
| 515 | // manageNav: who is here, what they may do, what the relay is, what it does |
| 516 | // over time, and how it is doing. |
| 517 | -var manageNav = []navItem{{"/people", "/manage/people", "people", 0}, {"/moderation", "/manage/moderation", "moderation", 1}, {"/rules", "/manage/rules", "rules", 1}, {"/identity", "/manage/identity", "identity", 2}, {"/connect", "/manage/connect", "connect", 2}, {"/owner", "/manage/owner", "owner", 2}, {"/sync", "/manage/sync", "sync", 3}, {"/data", "/manage/data", "data", 3}, {"/views", "/manage/views", "views", 3}, {"/health", "/manage/health", "health", 4}} |
| 518 | +var manageNav = []navItem{{"/people", "/manage/people", "people", 0}, {"/agents", "/manage/agents", "agents", 0}, {"/moderation", "/manage/moderation", "moderation", 1}, {"/rules", "/manage/rules", "rules", 1}, {"/identity", "/manage/identity", "identity", 2}, {"/connect", "/manage/connect", "connect", 2}, {"/owner", "/manage/owner", "owner", 2}, {"/sync", "/manage/sync", "sync", 3}, {"/data", "/manage/data", "data", 3}, {"/views", "/manage/views", "views", 3}, {"/health", "/manage/health", "health", 4}} |
| 519 | |
| 520 | // navGroups splits a rail list into its groups, in order. |
| 521 | func navGroups(items []navItem) [][]navItem { |
| 522 | @@ -243,6 +243,13 @@ func parseTemplates() (*template.Template, error) { |
| 523 | "collabItems": collaborationItems, |
| 524 | "collabReplies": collaborationReplies, |
| 525 | "collabLabels": collaborationLabels, |
| 526 | + "agentState": agentState, |
| 527 | + "agentSince": agentSince, |
| 528 | + "agentScope": agentScope, |
| 529 | + "agentLabel": agentLabel, |
| 530 | + "agentCounts": agentCounts, |
| 531 | + "scopeList": scopeList, |
| 532 | + "dateAfter": dateAfter, |
| 533 | } |
| 534 | tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html") |
| 535 | if err != nil { |
| 536 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 537 | index 458120d..e89cd0b 100644 |
| 538 | --- a/internal/webui/webui.go |
| 539 | +++ b/internal/webui/webui.go |
| 540 | @@ -820,12 +820,134 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) { |
| 541 | data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor}) |
| 542 | } |
| 543 | } |
| 544 | + if tab == "agents" && actor != "" { |
| 545 | + a.agentsPage(request, actor, &data) |
| 546 | + } |
| 547 | if tab != "" { |
| 548 | data.Title = tab + " | " + a.backend.Slug() |
| 549 | } |
| 550 | a.render(writer, request, data) |
| 551 | } |
| 552 | |
| 553 | +// agentsPage loads the agent list for the table and cards, then the grant |
| 554 | +// and recent events of one agent: the one named by ?agent=, else the first. |
| 555 | +// Both queries are gated for the owner and moderators by the backend. |
| 556 | +func (a *App) agentsPage(request *http.Request, actor string, data *PageData) { |
| 557 | + result, err := a.backend.Query(request.Context(), "listagents", nil, actor) |
| 558 | + if err != nil { |
| 559 | + data.Error = err.Error() |
| 560 | + return |
| 561 | + } |
| 562 | + data.Feed = browseRows(result) |
| 563 | + selected := request.URL.Query().Get("agent") |
| 564 | + if selected == "" && len(data.Feed) > 0 { |
| 565 | + selected = plainString(valueMap(data.Feed[0])["pubkey"]) |
| 566 | + } |
| 567 | + if selected == "" { |
| 568 | + return |
| 569 | + } |
| 570 | + raw, _ := json.Marshal(map[string]any{"agent": selected}) |
| 571 | + detail, err := a.backend.Query(request.Context(), "browseagent", []json.RawMessage{raw}, actor) |
| 572 | + if err != nil { |
| 573 | + data.Error = err.Error() |
| 574 | + return |
| 575 | + } |
| 576 | + data.Event = detail |
| 577 | +} |
| 578 | + |
| 579 | +// agentState folds a grant's marks into one word for data-state: revoked |
| 580 | +// covers expired grants too, since neither lets the agent publish again |
| 581 | +// without a new grant. |
| 582 | +func agentState(agent any) string { |
| 583 | + values := valueMap(agent) |
| 584 | + switch { |
| 585 | + case unixSeconds(values["revoked"]) > 0: |
| 586 | + return "revoked" |
| 587 | + case unixSeconds(values["expires"]) > 0 && unixSeconds(values["expires"]) <= time.Now().Unix(): |
| 588 | + return "revoked" |
| 589 | + case values["paused"] == true: |
| 590 | + return "paused" |
| 591 | + default: |
| 592 | + return "active" |
| 593 | + } |
| 594 | +} |
| 595 | + |
| 596 | +// agentSince is the short time shown beside the state: when the grant was |
| 597 | +// revoked or expired, else the agent's newest event. |
| 598 | +func agentSince(agent any) string { |
| 599 | + values := valueMap(agent) |
| 600 | + if revoked := unixSeconds(values["revoked"]); revoked > 0 { |
| 601 | + return short(revoked) |
| 602 | + } |
| 603 | + if expires := unixSeconds(values["expires"]); expires > 0 && expires <= time.Now().Unix() { |
| 604 | + return "expired " + short(expires) |
| 605 | + } |
| 606 | + if last := short(values["lastEvent"]); last != "" { |
| 607 | + return last |
| 608 | + } |
| 609 | + return "" |
| 610 | +} |
| 611 | + |
| 612 | +// agentLabel is the grant's name, or the shortened key when it has none. |
| 613 | +func agentLabel(agent any) string { |
| 614 | + values := valueMap(agent) |
| 615 | + if name := plainString(values["name"]); name != "" { |
| 616 | + return name |
| 617 | + } |
| 618 | + return shortID(plainString(values["pubkey"])) |
| 619 | +} |
| 620 | + |
| 621 | +// scopeList joins one list from the grant scope: rooms or kinds. |
| 622 | +func scopeList(agent any, key string) string { |
| 623 | + items, _ := valueMap(valueMap(agent)["scope"])[key].([]any) |
| 624 | + parts := make([]string, 0, len(items)) |
| 625 | + for _, item := range items { |
| 626 | + parts = append(parts, plainString(item)) |
| 627 | + } |
| 628 | + return strings.Join(parts, ", ") |
| 629 | +} |
| 630 | + |
| 631 | +// agentScope summarises a grant on one line for the agent table. |
| 632 | +func agentScope(agent any) string { |
| 633 | + scope := valueMap(valueMap(agent)["scope"]) |
| 634 | + var parts []string |
| 635 | + if rooms := scopeList(agent, "rooms"); rooms != "" { |
| 636 | + parts = append(parts, "rooms: "+rooms) |
| 637 | + } |
| 638 | + if repos, _ := scope["repos"].([]any); len(repos) > 0 { |
| 639 | + names := make([]string, 0, len(repos)) |
| 640 | + for _, repo := range repos { |
| 641 | + values := valueMap(repo) |
| 642 | + names = append(names, plainString(values["identifier"])+" ("+plainString(values["level"])+")") |
| 643 | + } |
| 644 | + parts = append(parts, "repos: "+strings.Join(names, ", ")) |
| 645 | + } |
| 646 | + if wiki := plainString(scope["wiki"]); wiki != "" { |
| 647 | + parts = append(parts, "wiki: "+wiki) |
| 648 | + } |
| 649 | + if kinds := scopeList(agent, "kinds"); kinds != "" { |
| 650 | + parts = append(parts, "kinds: "+kinds) |
| 651 | + } |
| 652 | + if len(parts) == 0 { |
| 653 | + return "profile and relay list only" |
| 654 | + } |
| 655 | + return strings.Join(parts, " | ") |
| 656 | +} |
| 657 | + |
| 658 | +// agentCounts tallies agents by state for the panel. |
| 659 | +func agentCounts(agents []any) map[string]int { |
| 660 | + counts := map[string]int{"active": 0, "paused": 0, "revoked": 0} |
| 661 | + for _, agent := range agents { |
| 662 | + counts[agentState(agent)]++ |
| 663 | + } |
| 664 | + return counts |
| 665 | +} |
| 666 | + |
| 667 | +// dateAfter is the date field value for a day count from today, in UTC. |
| 668 | +func dateAfter(days int) string { |
| 669 | + return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02") |
| 670 | +} |
| 671 | + |
| 672 | var supportedMethods = []string{ |
| 673 | "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", |
| 674 | } |
| 675 | @@ -936,7 +1058,7 @@ func tabForPath(path string) string { |
| 676 | return "file" |
| 677 | case "signin", "sites": |
| 678 | return path |
| 679 | - case "people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner": |
| 680 | + case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner": |
| 681 | return path |
| 682 | case "inbox", "outbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "terms": |
| 683 | return path |
| 684 | diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go |
| 685 | index e73f8fd..85aa48f 100644 |
| 686 | --- a/internal/webui/webui_test.go |
| 687 | +++ b/internal/webui/webui_test.go |
| 688 | @@ -45,7 +45,7 @@ func TestPagesUsePlainHTMLAndExposeAllManagementTabs(t *testing.T) { |
| 689 | if err != nil { |
| 690 | t.Fatal(err) |
| 691 | } |
| 692 | - for _, path := range []string{"/", "/inbox", "/outbox", "/manage/people", "/manage/moderation", "/manage/rules", "/manage/identity", "/manage/connect", "/manage/data", "/manage/sync", "/manage/views", "/manage/health", "/manage/owner"} { |
| 693 | + for _, path := range []string{"/", "/inbox", "/outbox", "/manage/people", "/manage/agents", "/manage/moderation", "/manage/rules", "/manage/identity", "/manage/connect", "/manage/data", "/manage/sync", "/manage/views", "/manage/health", "/manage/owner"} { |
| 694 | request := httptest.NewRequest(http.MethodGet, path, nil) |
| 695 | recorder := httptest.NewRecorder() |
| 696 | app.ServeHTTP(recorder, request) |
| 697 | diff --git a/package.json b/package.json |
| 698 | index e8ff5ef..1548abc 100644 |
| 699 | --- a/package.json |
| 700 | +++ b/package.json |
| 701 | @@ -8,7 +8,8 @@ |
| 702 | "test:webmcp": "node --test scripts/webmcp.test.mjs", |
| 703 | "test:signer": "node --test scripts/signer.test.mjs", |
| 704 | "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs scripts/tiny-namespace.test.mjs", |
| 705 | - "test:collaboration": "node --test scripts/collaboration-compose.test.mjs" |
| 706 | + "test:collaboration": "node --test scripts/collaboration-compose.test.mjs", |
| 707 | + "test:agents": "node --test scripts/agent-grant.test.mjs" |
| 708 | }, |
| 709 | "devDependencies": { |
| 710 | "@noble/hashes": "2.4.0", |
| 711 | diff --git a/scripts/agent-grant.test.mjs b/scripts/agent-grant.test.mjs |
| 712 | new file mode 100644 |
| 713 | index 0000000..50f13b6 |
| 714 | --- /dev/null |
| 715 | +++ b/scripts/agent-grant.test.mjs |
| 716 | @@ -0,0 +1,133 @@ |
| 717 | +import test from "node:test"; |
| 718 | +import assert from "node:assert/strict"; |
| 719 | +import fs from "node:fs"; |
| 720 | +import vm from "node:vm"; |
| 721 | +import {finalizeEvent, generateSecretKey, getPublicKey, nip19, verifyEvent} from "nostr-tools"; |
| 722 | + |
| 723 | +const owner = generateSecretKey(); |
| 724 | +const ownerPubkey = getPublicKey(owner); |
| 725 | +const repoOwner = "b".repeat(64); |
| 726 | +const source = fs.readFileSync("internal/webui/components.js", "utf8"); |
| 727 | +const tinySource = fs.readFileSync("internal/webui/tiny.js", "utf8"); |
| 728 | +const grantSource = source.slice(source.indexOf(" class AgentGrant "), source.indexOf(" class NostrKey ")); |
| 729 | +const today = new Date(); |
| 730 | +const dateAfter = days => new Date(today.getTime() + days * 86400000).toISOString().slice(0, 10); |
| 731 | + |
| 732 | +function setup(values = {}, options = {}) { |
| 733 | + const sent = [], signed = [], appended = []; |
| 734 | + let resets = 0, navigated = 0; |
| 735 | + class FormElement { |
| 736 | + getAttribute() { return null; } |
| 737 | + report(message) { this.message = message; } |
| 738 | + append(...nodes) { appended.push(...nodes); } |
| 739 | + } |
| 740 | + const el = (name, text) => ({name, text, children: [], attributes: {}, append(...nodes) { this.children.push(...nodes); }, setAttribute(key, value) { this.attributes[key] = value; }, addEventListener() {}}); |
| 741 | + const tinySandbox = {globalThis: {}, crypto: {}, TextEncoder, URL}; |
| 742 | + vm.runInNewContext(tinySource, tinySandbox); |
| 743 | + const window = { |
| 744 | + NostrSigner: {verifyEvent, generateSecretKey, getPublicKey}, |
| 745 | + nostr: {signEvent: async event => { |
| 746 | + signed.push(structuredClone(event)); |
| 747 | + if (options.change) event.tags.push(["k", "7"]); |
| 748 | + return finalizeEvent(structuredClone(event), options.signer || owner); |
| 749 | + }} |
| 750 | + }; |
| 751 | + const tiny = { |
| 752 | + util: tinySandbox.globalThis.tiny.util, |
| 753 | + navigate: async () => { navigated++; }, |
| 754 | + signedFetch: async (path, method, body) => { |
| 755 | + sent.push({path, method, event: JSON.parse(body)}); |
| 756 | + return Response.json(options.result ?? {accepted: true}); |
| 757 | + } |
| 758 | + }; |
| 759 | + const Constructor = vm.runInNewContext(`${grantSource}\nAgentGrant`, {FormElement, window, tiny, el, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || ""), globalThis: {location: {pathname: "/manage/agents"}}}); |
| 760 | + const component = new Constructor(); |
| 761 | + 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++; }}; |
| 762 | + return {Constructor, component, form, sent, signed, appended, resets: () => resets, navigated: () => navigated}; |
| 763 | +} |
| 764 | + |
| 765 | +test("a generated key yields a signed grant, an nsec shown once and no reload", async () => { |
| 766 | + const s = setup({rooms: "build, agents", repos: `${repoOwner}:tinyrelay:maintain\n${repoOwner}:docs:read`, kinds: "9, 1111, 1621", wiki: "propose", rate: "30"}); |
| 767 | + await s.component.submit(s.form); |
| 768 | + assert.equal(s.sent.length, 1); |
| 769 | + const {event} = s.sent[0]; |
| 770 | + assert.equal(s.sent[0].path, "/events"); |
| 771 | + assert.equal(event.kind, 30392); |
| 772 | + assert.equal(event.pubkey, ownerPubkey); |
| 773 | + assert.equal(verifyEvent(event), true); |
| 774 | + const agent = event.tags[0][1]; |
| 775 | + assert.match(agent, /^[0-9a-f]{64}$/); |
| 776 | + assert.notEqual(agent, ownerPubkey); |
| 777 | + const expiration = Number(event.tags.find(tag => tag[0] === "expiration")[1]); |
| 778 | + const now = Math.floor(Date.now() / 1000); |
| 779 | + assert.ok(expiration > now + 89 * 86400 && expiration <= now + 91 * 86400, "expiration is about 90 days out"); |
| 780 | + assert.deepEqual(event.tags.filter(tag => tag[0] !== "expiration"), [ |
| 781 | + ["d", agent], ["p", agent], ["name", "helper"], |
| 782 | + ["room", "build"], ["room", "agents"], |
| 783 | + ["repo", `${repoOwner}:tinyrelay:maintain`], ["repo", `${repoOwner}:docs:read`], |
| 784 | + ["k", "9"], ["k", "1111"], ["k", "1621"], |
| 785 | + ["wiki", "propose"], ["rate", "30"] |
| 786 | + ]); |
| 787 | + assert.equal(event.content, ""); |
| 788 | + // The secret is shown once, in a read-only field, and matches the agent key. |
| 789 | + const secretInput = s.appended.flatMap(node => node.children || []).find(node => typeof node.value === "string" && node.value.startsWith("nsec1")); |
| 790 | + assert.ok(secretInput, "nsec field appended"); |
| 791 | + assert.equal(secretInput.readOnly, true); |
| 792 | + const decoded = nip19.decode(secretInput.value); |
| 793 | + assert.equal(decoded.type, "nsec"); |
| 794 | + assert.equal(getPublicKey(decoded.data), agent); |
| 795 | + assert.ok(s.appended.some(node => node.attributes?.role === "alert"), "warning shown"); |
| 796 | + assert.equal(s.navigated(), 0); |
| 797 | + assert.equal(s.resets(), 0); |
| 798 | + assert.equal(s.component.message, "Granted."); |
| 799 | +}); |
| 800 | + |
| 801 | +test("a pasted public key signs the grant and reloads the page", async () => { |
| 802 | + const agent = "c".repeat(64); |
| 803 | + const s = setup({key: "paste", pubkey: " " + agent.toUpperCase() + " ", name: "", rate: ""}); |
| 804 | + await s.component.submit(s.form); |
| 805 | + const {event} = s.sent[0]; |
| 806 | + assert.deepEqual(event.tags.map(tag => tag[0]), ["d", "p", "expiration"]); |
| 807 | + assert.equal(event.tags[0][1], agent); |
| 808 | + assert.equal(s.appended.length, 0); |
| 809 | + assert.equal(s.navigated(), 1); |
| 810 | + assert.equal(s.resets(), 1); |
| 811 | +}); |
| 812 | + |
| 813 | +test("invalid fields are refused before anything is signed", async () => { |
| 814 | + for (const [values, message] of [ |
| 815 | + [{key: "paste", pubkey: "nope"}, /public key/], |
| 816 | + [{name: "x".repeat(65)}, /64 characters/], |
| 817 | + [{expires: dateAfter(-1)}, /after today/], |
| 818 | + [{expires: dateAfter(400)}, /365 days/], |
| 819 | + [{repos: "tinyrelay:maintain"}, /Repositories are/], |
| 820 | + [{repos: `${repoOwner}:tinyrelay:admin`}, /Repositories are/], |
| 821 | + [{kinds: "1, abc"}, /Kinds are/], |
| 822 | + [{kinds: "70000"}, /Kinds are/], |
| 823 | + [{wiki: "delete"}, /propose or edit/], |
| 824 | + [{rate: "0"}, /1 to 600/], |
| 825 | + [{rate: "601"}, /1 to 600/] |
| 826 | + ]) { |
| 827 | + const s = setup(values); |
| 828 | + await assert.rejects(s.component.submit(s.form), message); |
| 829 | + assert.equal(s.signed.length, 0); |
| 830 | + assert.equal(s.sent.length, 0); |
| 831 | + } |
| 832 | +}); |
| 833 | + |
| 834 | +test("a changed event, a self grant and a relay refusal keep the form", async () => { |
| 835 | + const changed = setup({}, {change: true}); |
| 836 | + await assert.rejects(changed.component.submit(changed.form), /invalid or changed/); |
| 837 | + assert.equal(changed.sent.length, 0); |
| 838 | + const refused = setup({}, {result: {accepted: false, message: "restricted: moderator required"}}); |
| 839 | + await assert.rejects(refused.component.submit(refused.form), /moderator required/); |
| 840 | + assert.equal(refused.appended.length, 0); |
| 841 | + assert.equal(refused.resets(), 0); |
| 842 | +}); |
| 843 | + |
| 844 | +test("tags dedupe rooms, repositories and kinds", () => { |
| 845 | + const {Constructor} = setup(); |
| 846 | + const agent = "d".repeat(64); |
| 847 | + const tags = Constructor.tags({name: " bot ", expires: dateAfter(10), rooms: "a a, b", repos: `${repoOwner}:r:read\n${repoOwner}:r:maintain`, kinds: "1 1 2", wiki: "", rate: ""}, agent); |
| 848 | + assert.deepEqual(JSON.parse(JSON.stringify(tags)).filter(tag => tag[0] !== "expiration"), [["d", agent], ["p", agent], ["name", "bot"], ["room", "a"], ["room", "b"], ["repo", `${repoOwner}:r:read`], ["k", "1"], ["k", "2"]]); |
| 849 | +}); |