clone

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

accept propose as a repository grant level

Dami committed 615c53c
commit
615c53c549552527b365ed0cce361473c5106b1a
parent
56953bfd17dc1dc175909d07aeffe943701a4b9c
author
Dami <dami.etoile@gmail.com>
6 files changed, +55 -14
Minternal/community/agents.go29+++++++++++++++++++++++------
Minternal/gates/agents_test.go16++++++++++++++--
Minternal/webui/agents_test.go9++++++---
Minternal/webui/components.js4++--
Minternal/webui/manage.html2+-
Mscripts/agent-grant.test.mjs9+++++++++
M internal/community/agents.go +23 -6
@@ -32,14 +32,23 @@ const (
3232 3333 const agentSchema = `CREATE TABLE IF NOT EXISTS agent_grants(agent TEXT PRIMARY KEY,owner TEXT NOT NULL,event_id TEXT NOT NULL,name TEXT NOT NULL DEFAULT '',expires_at INTEGER NOT NULL,paused INTEGER NOT NULL DEFAULT 0,revoked_at INTEGER NOT NULL DEFAULT 0,scope TEXT NOT NULL DEFAULT '{}');`3434 35-// AgentRepo is one repository an agent may work in. Level is "read" or36-// "maintain"; maintain includes read.35+// AgentRepo is one repository an agent may work in. Level is "propose",36+// "read" or "maintain". Propose and read publish the same kinds, but each37+// event from a propose grant is a proposal that waits for a maintainer's38+// decision; maintain adds status changes and pushes.3739 type AgentRepo struct {3840 Owner string `json:"owner"`3941 Identifier string `json:"identifier"`4042 Level string `json:"level"`4143 }4244 45+// Repository grant levels.46+const (47+ AgentRepoPropose = "propose"48+ AgentRepoRead = "read"49+ AgentRepoMaintain = "maintain"50+)51+4352 // Long task grants. AgentJobsRequest lets the agent publish job requests,4453 // AgentJobsServe lets it answer requests the relay holds with results and4554 // feedback, and AgentJobsBoth does both.
@@ -96,7 +105,15 @@ func (g AgentGrant) Active(now int64) bool {
96105 // Maintains reports whether the grant currently makes the agent a maintainer97106 // of one repository: it is active and holds maintain on that exact repository.98107 func (g AgentGrant) Maintains(owner, identifier string, now int64) bool {99- return g.Active(now) && g.RepoLevel(owner, identifier) == "maintain"108+ return g.Active(now) && g.RepoLevel(owner, identifier) == AgentRepoMaintain109+}110+111+// Proposes reports whether the grant holds propose on one repository, so112+// the agent's issues, pull requests, patches and comments there are113+// proposals. A paused, revoked or expired grant keeps counting, so an114+// agent's pending events do not surface when its grant ends.115+func (g AgentGrant) Proposes(owner, identifier string) bool {116+ return g.RepoLevel(owner, identifier) == AgentRepoPropose100117 }101118 102119 // AllowsKind reports whether the agent may publish this kind. Profiles, relay
@@ -245,7 +262,7 @@ func (g AgentGrant) Check(e event.Event, now int64) error {
245262 if level == "" {246263 return fmt.Errorf("restricted: agent grant does not allow repository %s:%s", coordinate[0], coordinate[1])247264 }248- if gitStatusKind(e.Kind) && level != "maintain" {265+ if gitStatusKind(e.Kind) && level != AgentRepoMaintain {249266 return fmt.Errorf("restricted: agent grant does not allow status changes in repository %s:%s", coordinate[0], coordinate[1])250267 }251268 }
@@ -344,7 +361,7 @@ func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
344361 for _, value := range event.TagValues(e, "repo") {345362 repo, ok := parseAgentRepo(value)346363 if !ok {347- return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier:read or owner:identifier:maintain")364+ return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier followed by :propose, :read or :maintain")348365 }349366 if grant.RepoLevel(repo.Owner, repo.Identifier) == "" {350367 grant.Scope.Repos = append(grant.Scope.Repos, repo)
@@ -408,7 +425,7 @@ func parseAgentRepo(value string) (AgentRepo, bool) {
408425 return AgentRepo{}, false409426 }410427 level := value[last+1:]411- if level != "read" && level != "maintain" {428+ if level != AgentRepoPropose && level != AgentRepoRead && level != AgentRepoMaintain {412429 return AgentRepo{}, false413430 }414431 parts := strings.SplitN(value[:last], ":", 2)
M internal/gates/agents_test.go +14 -2
@@ -132,15 +132,27 @@ func TestAgentGrantScopesKindsRoomsAndRepositories(t *testing.T) {
132132 if err := f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow); err != nil {133133 t.Fatalf("maintainer status rejected: %v", err)134134 }135+ // Propose publishes the same kinds as read; status changes still need136+ // maintain.137+ f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":propose"}, []string{"k", "1621"}, []string{"k", "1618"}, []string{"k", "1617"}, []string{"k", "1111"}, []string{"k", "1630"}))138+ for _, kind := range []int{1621, 1618, 1617, 1111} {139+ if err := f.gate.Write(ctx, signed(t, agentSecret, kind, agentNow, [][]string{{"a", address}}, "proposal"), session, agentNow); err != nil {140+ t.Fatalf("kind %d in proposable repository rejected: %v", kind, err)141+ }142+ }143+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow), "status changes")144+ if _, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":admin"}), ""), agentNow); err == nil || !strings.Contains(err.Error(), ":propose, :read or :maintain") {145+ t.Fatalf("unknown level accepted: %v", err)146+ }135147 // Wiki access carries the wiki kinds without listing them.136- f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"wiki", "propose"}))148+ f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"wiki", "propose"}))137149 for _, kind := range []int{event.KIND_WIKI_ARTICLE, event.KIND_WIKI_MERGE} {138150 if err := f.gate.Write(ctx, signed(t, agentSecret, kind, agentNow, [][]string{{"d", "notes"}, {"title", "Notes"}, {"a", "30818:" + f.owner + ":notes"}, {"p", f.owner}, {"e", strings.Repeat("e", 64), "", "source"}}, "wiki"), session, agentNow); err != nil {139151 t.Fatalf("wiki kind %d rejected for propose: %v", kind, err)140152 }141153 }142154 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}}, ""), session, agentNow), "kind 30819")143- f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"wiki", "edit"}))155+ f.grant(t, agentNow+4, grantTags(f.agent, agentNow+3600, []string{"wiki", "edit"}))144156 if err := f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}, {"redirect", "30818:" + f.owner + ":notes"}}, ""), session, agentNow); err != nil {145157 t.Fatalf("redirect rejected for edit: %v", err)146158 }
M internal/webui/agents_test.go +6 -3
@@ -34,7 +34,7 @@ func (b *agentsBackend) Query(_ context.Context, method string, params []json.Ra
3434 scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any {3535 return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60}3636 }37- hermes := scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})37+ hermes := scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}, {"owner": owner, "identifier": "docs", "level": "propose"}}, "propose", []int{9, 1111, 1621})3838 hermes["sites"] = []map[string]any{{"label": agentSite, "ttl": 30, "encrypted": true}, {"label": "*"}}3939 agents := []any{4040 map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120, "scope": hermes},
@@ -92,7 +92,7 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
9292 `<a href="/manage/agents" aria-current="page">/agents</a>`,9393 `<table id="rows">`,9494 `<a href="#agent-` + agentActive + `">hermes</a>`,95- `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`,95+ `rooms: build, agents | repos: tinyrelay (maintain), docs (propose) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`,9696 `<tr><th>sites</th><td><code>` + agentSite + `</code> 30 days encrypted<br><code>*</code></td></tr>`,9797 `<tr><th>sites</th><td>none</td></tr>`,9898 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted&#10;* ttl=7"></textarea>`,
@@ -103,6 +103,8 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
103103 `<agent-card id="agent-` + agentActive + `">`,104104 `<h3>hermes <small><span data-state="active">active</span> | signed by you | expires `,105105 `<code>tinyrelay</code> maintain, owner <nostr-name pubkey="` + backend.policy.Owner + `"`,106+ `<br><code>docs</code> propose, owner <nostr-name pubkey="` + backend.policy.Owner + `"`,107+ `<textarea name="repos" placeholder="owner pubkey:identifier:propose&#10;owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain">`,106108 `<th>rate</th><td>60 events per minute</td>`,107109 `<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Pause</button></rpc-form>`,108110 `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Revoke</button></rpc-form>`,
@@ -239,7 +241,8 @@ func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
239241 `<option value="paste" selected>paste a public key</option>`,240242 `<input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters" value="` + agentActive + `">`,241243 `placeholder="build, agents" value="build, agents">`,242- `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain</textarea>`,244+ `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain245+` + strings.Repeat("a", 64) + `:docs:propose</textarea>`,243246 `<option value="propose" selected>propose</option>`,244247 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted&#10;* ttl=7">` + agentSite + ` ttl=30 encrypted245248 *</textarea>`,
M internal/webui/components.js +2 -2
M internal/webui/manage.html +1 -1
M scripts/agent-grant.test.mjs +9 -0