tinyrelaycommit

Commit a5b9173eb7d5

browse files at this commit | history

1diff --git a/docs/agents.md b/docs/agents.md
2index 047565e..e48aaf6 100644
3--- a/docs/agents.md
4+++ b/docs/agents.md
5@@ -59,6 +59,8 @@ Every event from an agent key passes these checks before it is stored, whether i
6 - Repository events name a repository the grant covers. Issues, patches, pull requests and comments need `read` or `maintain`. Status changes (kinds 1630 to 1633) need `maintain`.
7 - The agent has not exceeded its per-minute rate.
8
9+A `maintain` grant makes the agent a maintainer of that repository, the same as a key in the announcement's `maintainers` tag. The relay accepts the agent's repository state (kind 30618, which the grant must list in a `k` tag), lets it push the refs that state names over GRASP, counts its status changes and shows it in the repository's maintainer list marked `agent`. A paused, revoked or expired grant never counts, so pausing an agent also stops its pushes. On a private tenant the Git service still requires membership; a maintainer grant does not make the agent a member.
10+
11 Rejected events return a `restricted: agent grant ...` reason to the client. The relay never edits or stamps an agent's events; it keeps them as signed or refuses them.
12
13 The rest of the relay's policy still applies. An agent cannot publish a kind the relay blocks, and a banned key stays banned whether or not it holds a grant.
14@@ -85,7 +87,7 @@ Either way the agent loses its role at once. A fresh grant restores access.
15
16 ## Manage > Agents
17
18-The **Manage > Agents** page is where the owner and moderators see and control agents. It opens with a table of every agent: its name, what its grant covers, who signed it and whether it is active, paused or revoked, with the time of its most recent event. Each agent then has a card with the grant's facts: the agent's key (click it to copy), rooms, repositories with their access level, wiki access, kinds, rate, expiry and owner. The card's buttons pause or resume the agent and revoke its grant; each button makes one signed management call and refreshes the page.
19+The **Manage > Agents** page is where the owner and moderators see and control agents. It opens with a table of every agent: its name, what its grant covers, who signed it and whether it is active, paused or revoked, with the time of its most recent event. Each agent then has a card with the grant's facts: the agent's key (click it to copy), rooms, repositories with their access level (`read` or `maintain`), wiki access, kinds, rate, expiry and owner. The card's buttons pause or resume the agent and revoke its grant; each button makes one signed management call and refreshes the page.
20
21 Below the cards, **Recent activity** lists the 10 newest events from one agent. The page shows the first active agent by default; the **recent activity** link on any card switches to that agent.
22
23diff --git a/docs/git-collaboration.md b/docs/git-collaboration.md
24index 958b010..7499193 100644
25--- a/docs/git-collaboration.md
26+++ b/docs/git-collaboration.md
27@@ -6,7 +6,7 @@ Open a repository and choose **Issues** or **Pull requests**. Search titles and
28
29 Create an issue with a title, description and optional comma-separated labels. A pull request also needs the full Git commit ID and an HTTP or HTTPS clone URL. The optional merge base identifies the common ancestor with the target branch.
30
31-Replies can address the original post or an individual comment. The author, repository owner and current maintainers can change status. Issues support open, resolved, closed and draft states; pull requests use merged in place of resolved. Marking a pull request merged records its status; merge and push the Git changes with your Git client.
32+Replies can address the original post or an individual comment. The author, repository owner and current maintainers can change status. Maintainers are the keys in the announcement's `maintainers` tag and any [agent](agents.md) whose active grant holds `maintain` on the repository; the repository panel lists them and marks agents with the grant's name. Issues support open, resolved, closed and draft states; pull requests use merged in place of resolved. Marking a pull request merged records its status; merge and push the Git changes with your Git client.
33
34 Pull request diffs use commits available in the hosted repository. Missing commits produce an availability message. Diff previews are limited to 512 KiB, with further display limits for long output. Clone the repository to review the complete change.
35
36diff --git a/internal/community/agents.go b/internal/community/agents.go
37index e9cb0e1..23e9889 100644
38--- a/internal/community/agents.go
39+++ b/internal/community/agents.go
40@@ -80,6 +80,12 @@ func (g AgentGrant) Active(now int64) bool {
41 return !g.Paused && g.RevokedAt == 0 && g.ExpiresAt > now
42 }
43
44+// Maintains reports whether the grant currently makes the agent a maintainer
45+// of one repository: it is active and holds maintain on that exact repository.
46+func (g AgentGrant) Maintains(owner, identifier string, now int64) bool {
47+ return g.Active(now) && g.RepoLevel(owner, identifier) == "maintain"
48+}
49+
50 // AllowsKind reports whether the agent may publish this kind. Profiles, relay
51 // lists and auth are always allowed so an agent can identify itself.
52 func (g AgentGrant) AllowsKind(kind int) bool {
53@@ -444,6 +450,34 @@ func (s *Service) Agents(ctx context.Context) ([]AgentSummary, error) {
54 return out, rows.Err()
55 }
56
57+// RepositoryAgents lists the active grants that hold maintain on one
58+// repository, in name order. Paused, revoked and expired grants are left out.
59+func (s *Service) RepositoryAgents(ctx context.Context, owner, identifier string, now int64) ([]AgentGrant, error) {
60+ rows, err := s.store.DB().QueryContext(ctx, `SELECT agent,owner,event_id,name,expires_at,scope FROM agent_grants WHERE paused=0 AND revoked_at=0 AND expires_at>? ORDER BY name,agent`, now)
61+ if err != nil {
62+ if strings.Contains(err.Error(), "no such table") {
63+ return nil, nil
64+ }
65+ return nil, fmt.Errorf("list repository agents: %w", err)
66+ }
67+ defer rows.Close()
68+ var out []AgentGrant
69+ for rows.Next() {
70+ var grant AgentGrant
71+ var scope string
72+ if err := rows.Scan(&grant.Agent, &grant.Owner, &grant.EventID, &grant.Name, &grant.ExpiresAt, &scope); err != nil {
73+ return nil, err
74+ }
75+ if err := json.Unmarshal([]byte(scope), &grant.Scope); err != nil {
76+ return nil, fmt.Errorf("agent grant scope: %w", err)
77+ }
78+ if grant.Maintains(owner, identifier, now) {
79+ out = append(out, grant)
80+ }
81+ }
82+ return out, rows.Err()
83+}
84+
85 // ActiveAgentCount counts grants that currently allow publishing.
86 func (s *Service) ActiveAgentCount(ctx context.Context, now int64) (int, error) {
87 var count int
88diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
89index fb13995..69e1f7e 100644
90--- a/internal/daemon/client_browse.go
91+++ b/internal/daemon/client_browse.go
92@@ -122,7 +122,7 @@ func (t *Tenant) browseRepoAccess(ctx context.Context, actor string, r gitrelay.
93 if err != nil {
94 return err
95 }
96- if actor == r.Owner || role == "owner" || role == "moderator" || role == "member" {
97+ if role == "owner" || role == "moderator" || role == "member" || t.IsMaintainer(ctx, r, actor) {
98 return nil
99 }
100 return errors.New("restricted: private repository membership required")
101@@ -168,6 +168,7 @@ func (t *Tenant) browseRepo(ctx context.Context, actor string, q clientBrowseReq
102 return nil, err
103 }
104 page.Private = page.Private || t.PrivateServiceEnabled()
105+ page.Maintainers = t.repositoryMaintainers(ctx, r)
106 if q.View != "activity" {
107 return page, nil
108 }
109diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go
110index 2556f66..5ae16c5 100644
111--- a/internal/daemon/collaboration_browse.go
112+++ b/internal/daemon/collaboration_browse.go
113@@ -40,8 +40,8 @@ type collaborationDetail struct {
114 ReplyCursor string `json:"reply_cursor,omitempty"`
115 }
116
117-func collaborationRepository(repo gitrelay.Repository) map[string]any {
118- return map[string]any{"owner": repo.Owner, "private": repo.Private, "clone": repo.Clone, "refs": repo.Refs, "head": repo.Head}
119+func (t *Tenant) collaborationRepository(ctx context.Context, repo gitrelay.Repository) map[string]any {
120+ return map[string]any{"owner": repo.Owner, "private": repo.Private, "clone": repo.Clone, "refs": repo.Refs, "head": repo.Head, "maintainers": t.repositoryMaintainers(ctx, repo)}
121 }
122
123 func (t *Tenant) collaborationRepo(ctx context.Context, actor string, q clientBrowseRequest) (gitrelay.Repository, error) {
124@@ -116,7 +116,7 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
125 continue
126 }
127 if len(items) == q.Limit {
128- return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": next}, nil
129+ return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next}, nil
130 }
131 item := collaborationItemFrom(root)
132 item.Status = status
133@@ -124,13 +124,13 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
134 next = collaborationCursor(item)
135 }
136 if !more {
137- return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": ""}, nil
138+ return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": ""}, nil
139 }
140 }
141 if cursor != nil {
142 next = strconv.FormatInt(cursor.CreatedAt, 10) + ":" + cursor.ID
143 }
144- return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": next}, nil
145+ return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next}, nil
146 }
147
148 func parseCollaborationCursor(raw string) (*storage.EventCursor, error) {
149@@ -173,7 +173,7 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
150 return nil, errors.New("not found: collaboration event")
151 }
152 root := rows[0]
153- detail := collaborationDetail{Repository: collaborationRepository(r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor == root.PubKey || actor == r.Owner || contains(r.Maintainers, actor)}
154+ detail := collaborationDetail{Repository: t.collaborationRepository(ctx, r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor != "" && (actor == root.PubKey || t.IsMaintainer(ctx, r, actor))}
155 replyCursor, err := parseCollaborationCursor(q.Cursor)
156 if err != nil {
157 return nil, err
158@@ -182,7 +182,7 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
159 if replyLimit <= 0 || replyLimit > 200 {
160 replyLimit = 200
161 }
162- authors := append([]string{root.PubKey, r.Owner}, r.Maintainers...)
163+ authors := append([]string{root.PubKey}, t.maintainerKeys(ctx, r)...)
164 statuses, err := t.Query(ctx, []event.Filter{{Authors: authors, Kinds: []int{1630, 1631, 1632, 1633}, Tags: map[string][]string{"e": {root.ID}}, Limit: intPtr(100)}}, browseSession(t, actor))
165 if err != nil {
166 return nil, err
167@@ -281,7 +281,8 @@ func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots
168 return statuses, nil
169 }
170 ids := make([]string, 0, len(roots))
171- authors := append([]string{r.Owner}, r.Maintainers...)
172+ maintainers := t.maintainerKeys(ctx, r)
173+ authors := append([]string(nil), maintainers...)
174 for _, root := range roots {
175 ids = append(ids, root.ID)
176 authors = append(authors, root.PubKey)
177@@ -300,7 +301,7 @@ func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots
178 for _, root := range roots {
179 allowed := []event.Event{}
180 for _, row := range byRoot[root.ID] {
181- if row.PubKey == root.PubKey || row.PubKey == r.Owner || contains(r.Maintainers, row.PubKey) {
182+ if row.PubKey == root.PubKey || contains(maintainers, row.PubKey) {
183 allowed = append(allowed, row)
184 }
185 }
186diff --git a/internal/daemon/git_agent_maintainer_test.go b/internal/daemon/git_agent_maintainer_test.go
187new file mode 100644
188index 0000000..1912242
189--- /dev/null
190+++ b/internal/daemon/git_agent_maintainer_test.go
191@@ -0,0 +1,187 @@
192+package daemon
193+
194+import (
195+ "context"
196+ "encoding/json"
197+ "net/http/httptest"
198+ "os/exec"
199+ "path/filepath"
200+ "strings"
201+ "testing"
202+ "time"
203+
204+ "github.com/FelineStateMachine/tinyrelay/internal/event"
205+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
206+)
207+
208+const testOwnerNPub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"
209+
210+// agentTestRepo announces one repository from the test owner and returns a
211+// working clone with one commit on main, plus that commit's id.
212+func agentTestRepo(t *testing.T, tenant *Tenant, identifier string) (string, string) {
213+ t.Helper()
214+ now := time.Now().Unix()
215+ announcement := signedEvent(t, testOwnerSecret, event.KIND_REPO, now, [][]string{{"d", identifier}, {"clone", "http://relay.test/" + testOwnerNPub + "/" + identifier + ".git"}, {"relays", "ws://relay.test"}}, "")
216+ if err := publishAs(t, tenant, announcement); err != nil {
217+ t.Fatalf("announcement rejected: %v", err)
218+ }
219+ source := filepath.Join(t.TempDir(), "source.git")
220+ gitTest(t, "init", "--bare", source)
221+ work := filepath.Join(t.TempDir(), "work")
222+ gitTest(t, "clone", source, work)
223+ gitTest(t, "-C", work, "config", "user.email", "test@example.com")
224+ gitTest(t, "-C", work, "config", "user.name", "Test")
225+ if err := osWrite(filepath.Join(work, "README"), []byte("agent\n")); err != nil {
226+ t.Fatal(err)
227+ }
228+ gitTest(t, "-C", work, "add", "README")
229+ gitTest(t, "-C", work, "commit", "-m", "first")
230+ gitTest(t, "-C", work, "branch", "-M", "main")
231+ return work, strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))
232+}
233+
234+func agentStateEvent(t *testing.T, identifier, sha string, createdAt int64) event.Event {
235+ t.Helper()
236+ return signedEvent(t, testAgentSecret, event.KIND_REPO_STATE, createdAt, [][]string{{"d", identifier}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "")
237+}
238+
239+func waitForRef(t *testing.T, tenant *Tenant, owner, identifier, ref, sha string) {
240+ t.Helper()
241+ deadline := time.Now().Add(10 * time.Second)
242+ for time.Now().Before(deadline) {
243+ if r, err := tenant.git.BrowseRepository(owner, identifier); err == nil && r.Refs[ref] == sha {
244+ return
245+ }
246+ time.Sleep(20 * time.Millisecond)
247+ }
248+ t.Fatalf("%s did not reach %s", ref, sha)
249+}
250+
251+func TestAgentMaintainGrantMakesTheAgentARepositoryMaintainer(t *testing.T) {
252+ ctx := context.Background()
253+ app, tenant := testTenant(t)
254+ p := tenant.Policy()
255+ p.Features.Grasp = true
256+ if err := tenant.applyPolicy(ctx, p); err != nil {
257+ t.Fatal(err)
258+ }
259+ owner, _ := event.PublicKey(testOwnerSecret)
260+ agent, _ := event.PublicKey(testAgentSecret)
261+ now := time.Now().Unix()
262+ work, sha := agentTestRepo(t, tenant, "agentrepo")
263+ repo := gitrelay.Repository{Owner: owner, Identifier: "agentrepo"}
264+ coordinate := "30617:" + owner + ":agentrepo"
265+ grantWith := func(level string, createdAt int64) event.Event {
266+ return agentGrantEvent(t, agent, createdAt, createdAt+3600, []string{"repo", owner + ":agentrepo:" + level}, []string{"k", "30618"}, []string{"k", "1632"})
267+ }
268+
269+ // A read grant lets the agent publish, but it is not a maintainer.
270+ if err := publishAs(t, tenant, grantWith("read", now)); err != nil {
271+ t.Fatalf("read grant rejected: %v", err)
272+ }
273+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+1)); err == nil || !strings.Contains(err.Error(), "not an accepted repository maintainer") {
274+ t.Fatalf("state under a read grant: %v", err)
275+ }
276+ if tenant.IsMaintainer(ctx, repo, agent) {
277+ t.Fatal("read grant counted as maintainer")
278+ }
279+
280+ // A maintain grant makes the agent a maintainer: its state is accepted
281+ // and a push matching that state goes through.
282+ if err := publishAs(t, tenant, grantWith("maintain", now+2)); err != nil {
283+ t.Fatalf("maintain grant rejected: %v", err)
284+ }
285+ if !tenant.IsMaintainer(ctx, repo, agent) {
286+ t.Fatal("maintain grant did not count as maintainer")
287+ }
288+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+3)); err != nil {
289+ t.Fatalf("state under a maintain grant rejected: %v", err)
290+ }
291+ server := httptest.NewServer(app)
292+ defer server.Close()
293+ url := server.URL + "/r/main/" + testOwnerNPub + "/agentrepo.git"
294+ gitTest(t, "-C", work, "push", url, "refs/heads/main:refs/heads/main")
295+ waitForRef(t, tenant, owner, "agentrepo", "refs/heads/main", sha)
296+
297+ // Browse output names the agent as a maintainer with its grant name.
298+ raw, _ := json.Marshal(map[string]any{"owner": owner, "repo": "agentrepo", "view": "tree"})
299+ result, err := tenant.Execute(ctx, "", "browserepo", []json.RawMessage{raw})
300+ if err != nil {
301+ t.Fatalf("browserepo: %v", err)
302+ }
303+ page, ok := result.(gitrelay.BrowsePage)
304+ if !ok {
305+ t.Fatalf("browserepo result %T", result)
306+ }
307+ if len(page.Maintainers) != 2 || page.Maintainers[0] != (gitrelay.Maintainer{PubKey: owner, Role: "owner"}) || page.Maintainers[1] != (gitrelay.Maintainer{PubKey: agent, Role: "agent", Name: "helper"}) {
308+ t.Fatalf("maintainers = %+v", page.Maintainers)
309+ }
310+
311+ // The agent's status change counts on an issue it did not open.
312+ issue := signedEvent(t, testOwnerSecret, event.KIND_GIT_ISSUE, now+4, [][]string{{"a", coordinate}, {"p", owner}, {"subject", "Bug"}}, "Details")
313+ if err := publishAs(t, tenant, issue); err != nil {
314+ t.Fatalf("issue rejected: %v", err)
315+ }
316+ closed := signedEvent(t, testAgentSecret, 1632, now+5, [][]string{{"a", coordinate}, {"e", issue.ID, "", "root"}, {"p", owner}}, "")
317+ if err := publishAs(t, tenant, closed); err != nil {
318+ t.Fatalf("agent status rejected: %v", err)
319+ }
320+ raw, _ = json.Marshal(map[string]any{"owner": owner, "repo": "agentrepo", "event": issue.ID})
321+ result, err = tenant.Execute(ctx, agent, "browseissue", []json.RawMessage{raw})
322+ if err != nil {
323+ t.Fatalf("browseissue: %v", err)
324+ }
325+ detail := result.(collaborationDetail)
326+ if detail.Item.Status != "closed" || !detail.CanStatus {
327+ t.Fatalf("status = %q can_status = %v, want closed by the agent maintainer", detail.Item.Status, detail.CanStatus)
328+ }
329+ maintainers, _ := detail.Repository["maintainers"].([]gitrelay.Maintainer)
330+ if len(maintainers) != 2 || maintainers[1].Role != "agent" {
331+ t.Fatalf("detail maintainers = %+v", maintainers)
332+ }
333+
334+ // A second commit needs a new state; a paused grant signs nothing that
335+ // counts, so the push is refused by the signed-ref hook.
336+ if err := osWrite(filepath.Join(work, "README"), []byte("agent again\n")); err != nil {
337+ t.Fatal(err)
338+ }
339+ gitTest(t, "-C", work, "commit", "-am", "second")
340+ next := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))
341+ if _, err := tenant.community.SetAgentPaused(ctx, owner, agent, true); err != nil {
342+ t.Fatal(err)
343+ }
344+ if tenant.IsMaintainer(ctx, repo, agent) {
345+ t.Fatal("paused grant counted as maintainer")
346+ }
347+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", next, now+6)); err == nil || !strings.Contains(err.Error(), "paused") {
348+ t.Fatalf("state under a paused grant: %v", err)
349+ }
350+ if out, err := exec.Command("git", "-C", work, "push", url, "refs/heads/main:refs/heads/main").CombinedOutput(); err == nil {
351+ t.Fatalf("push without a matching signed state succeeded: %s", out)
352+ }
353+ if page, _ := tenant.Execute(ctx, "", "browserepo", []json.RawMessage{raw}); len(page.(gitrelay.BrowsePage).Maintainers) != 1 {
354+ t.Fatalf("paused agent still listed: %+v", page.(gitrelay.BrowsePage).Maintainers)
355+ }
356+
357+ // Resumed, the agent maintains again; revoked, it never does.
358+ if _, err := tenant.community.SetAgentPaused(ctx, owner, agent, false); err != nil {
359+ t.Fatal(err)
360+ }
361+ if !tenant.IsMaintainer(ctx, repo, agent) {
362+ t.Fatal("resumed grant did not count as maintainer")
363+ }
364+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", next, now+7)); err != nil {
365+ t.Fatalf("state after resume rejected: %v", err)
366+ }
367+ gitTest(t, "-C", work, "push", url, "refs/heads/main:refs/heads/main")
368+ waitForRef(t, tenant, owner, "agentrepo", "refs/heads/main", next)
369+ if _, err := tenant.community.RevokeAgent(ctx, owner, agent, now+8); err != nil {
370+ t.Fatal(err)
371+ }
372+ if tenant.IsMaintainer(ctx, repo, agent) {
373+ t.Fatal("revoked grant counted as maintainer")
374+ }
375+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+9)); err == nil || !strings.Contains(err.Error(), "revoked") {
376+ t.Fatalf("state under a revoked grant: %v", err)
377+ }
378+}
379diff --git a/internal/daemon/repository_maintainers.go b/internal/daemon/repository_maintainers.go
380new file mode 100644
381index 0000000..70096fa
382--- /dev/null
383+++ b/internal/daemon/repository_maintainers.go
384@@ -0,0 +1,85 @@
385+package daemon
386+
387+import (
388+ "context"
389+ "time"
390+
391+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
392+)
393+
394+// Repository maintainers. The owner and the keys in an announcement's
395+// maintainers tag are maintainers by the event itself; an agent becomes one
396+// through a kind 30392 grant that holds maintain on that exact repository.
397+// This file is the one place that answers "does this key maintain this
398+// repository", for state events and pushes through gitrelay, for status
399+// changes and access checks in the daemon, and for the browse output that
400+// the repository panel lists.
401+
402+// IsMaintainer implements gitrelay.MaintainerSource. A paused, revoked or
403+// expired grant never counts. Outcomes are recorded under the authorize
404+// operation; the key itself is never logged or labelled.
405+func (t *Tenant) IsMaintainer(ctx context.Context, r gitrelay.Repository, pubkey string) bool {
406+ if pubkey == "" {
407+ return false
408+ }
409+ if pubkey == r.Owner || contains(r.Maintainers, pubkey) {
410+ return true
411+ }
412+ ctx, finish := t.app.telemetry.Start(ctx, "authorize")
413+ outcome := "unauthorized"
414+ defer func() { finish(outcome) }()
415+ grant, ok, err := t.community.AgentGrant(ctx, pubkey)
416+ if err != nil {
417+ outcome = "error"
418+ t.app.telemetry.Logger().WarnContext(ctx, "agent maintainer lookup failed", "error", err)
419+ return false
420+ }
421+ if ok && grant.Maintains(r.Owner, r.Identifier, time.Now().Unix()) {
422+ outcome = "ok"
423+ return true
424+ }
425+ return false
426+}
427+
428+// repositoryMaintainers lists the owner, the announcement's maintainers and
429+// every agent whose active grant holds maintain on the repository, in that
430+// order. A key in the maintainers tag that also holds such a grant is
431+// reported as an agent so the interface can say so.
432+func (t *Tenant) repositoryMaintainers(ctx context.Context, r gitrelay.Repository) []gitrelay.Maintainer {
433+ out := []gitrelay.Maintainer{{PubKey: r.Owner, Role: "owner"}}
434+ seen := map[string]int{r.Owner: 0}
435+ for _, pubkey := range r.Maintainers {
436+ if _, dup := seen[pubkey]; dup {
437+ continue
438+ }
439+ seen[pubkey] = len(out)
440+ out = append(out, gitrelay.Maintainer{PubKey: pubkey, Role: "maintainer"})
441+ }
442+ grants, err := t.community.RepositoryAgents(ctx, r.Owner, r.Identifier, time.Now().Unix())
443+ if err != nil {
444+ t.app.telemetry.Logger().WarnContext(ctx, "repository agents lookup failed", "error", err)
445+ return out
446+ }
447+ for _, grant := range grants {
448+ if index, dup := seen[grant.Agent]; dup {
449+ if index > 0 {
450+ out[index].Role, out[index].Name = "agent", grant.Name
451+ }
452+ continue
453+ }
454+ seen[grant.Agent] = len(out)
455+ out = append(out, gitrelay.Maintainer{PubKey: grant.Agent, Role: "agent", Name: grant.Name})
456+ }
457+ return out
458+}
459+
460+// maintainerKeys returns the keys whose status events count for a
461+// repository, for use as an authors filter.
462+func (t *Tenant) maintainerKeys(ctx context.Context, r gitrelay.Repository) []string {
463+ maintainers := t.repositoryMaintainers(ctx, r)
464+ keys := make([]string, 0, len(maintainers))
465+ for _, maintainer := range maintainers {
466+ keys = append(keys, maintainer.PubKey)
467+ }
468+ return keys
469+}
470diff --git a/internal/daemon/services.go b/internal/daemon/services.go
471index 9d7af96..f8ed719 100644
472--- a/internal/daemon/services.go
473+++ b/internal/daemon/services.go
474@@ -118,7 +118,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
475 if err := t.reconcileAutomaticInbox(ctx, currentPolicy, currentPolicy); err != nil {
476 return err
477 }
478- t.git, err = gitrelay.New(gitrelay.Config{Store: t.store, Root: t.meta.Paths.Git, Policy: t.Policy, PublicURL: t.publicURL, AllowPrivateRelays: t.app.cfg.AllowPrivateRelays, PrivatePeers: t.Policy().PrivatePeers, HTTPAuth: t.privateHTTPAuth, GitSync: t.gitSync, EventSync: t.gitEventSync, AuthorizeHTTP: t.authorizeGit, OnPromote: func(ctx context.Context, id string, _ gitrelay.Repository) error {
479+ t.git, err = gitrelay.New(gitrelay.Config{Store: t.store, Root: t.meta.Paths.Git, Policy: t.Policy, PublicURL: t.publicURL, AllowPrivateRelays: t.app.cfg.AllowPrivateRelays, PrivatePeers: t.Policy().PrivatePeers, HTTPAuth: t.privateHTTPAuth, GitSync: t.gitSync, EventSync: t.gitEventSync, AuthorizeHTTP: t.authorizeGit, Maintainers: t, OnPromote: func(ctx context.Context, id string, _ gitrelay.Repository) error {
480 return t.releaseGit(ctx, id)
481 }})
482 if err != nil {
483@@ -383,7 +383,7 @@ func (t *Tenant) authorizeGit(ctx context.Context, r *http.Request, repo gitrela
484 if err := t.requirePrivateAccess(ctx, e.PubKey); err != nil {
485 return err
486 }
487- } else if e.PubKey != repo.Owner {
488+ } else if !t.IsMaintainer(ctx, repo, e.PubKey) {
489 role, roleErr := t.community.Role(ctx, e.PubKey)
490 if roleErr != nil || (role != "owner" && role != "moderator" && role != "member") {
491 return errors.New("restricted: repository authorization required")
492diff --git a/internal/gitrelay/browse.go b/internal/gitrelay/browse.go
493index b8060dd..d85c4df 100644
494--- a/internal/gitrelay/browse.go
495+++ b/internal/gitrelay/browse.go
496@@ -46,23 +46,25 @@ type BrowseCommit struct {
497 }
498
499 type BrowsePage struct {
500- Owner string `json:"owner"`
501- Identifier string `json:"identifier"`
502- Head string `json:"head"`
503- Private bool `json:"private"`
504- Clone []string `json:"clone"`
505- Relays []string `json:"relays"`
506- Refs map[string]string `json:"refs"`
507- Commit string `json:"commit"`
508- Path string `json:"path"`
509- View string `json:"view"`
510- Entries []BrowseEntry `json:"entries"`
511- Content string `json:"content"`
512- Binary bool `json:"binary"`
513- Truncated bool `json:"truncated"`
514- Commits []BrowseCommit `json:"commits"`
515- Diff string `json:"diff"`
516- NextOffset int `json:"next_offset"`
517+ Owner string `json:"owner"`
518+ Identifier string `json:"identifier"`
519+ Head string `json:"head"`
520+ Private bool `json:"private"`
521+ Clone []string `json:"clone"`
522+ Relays []string `json:"relays"`
523+ // Maintainers is filled by the host, which knows about agent grants.
524+ Maintainers []Maintainer `json:"maintainers,omitempty"`
525+ Refs map[string]string `json:"refs"`
526+ Commit string `json:"commit"`
527+ Path string `json:"path"`
528+ View string `json:"view"`
529+ Entries []BrowseEntry `json:"entries"`
530+ Content string `json:"content"`
531+ Binary bool `json:"binary"`
532+ Truncated bool `json:"truncated"`
533+ Commits []BrowseCommit `json:"commits"`
534+ Diff string `json:"diff"`
535+ NextOffset int `json:"next_offset"`
536 }
537
538 // Repositories returns detached snapshots. Callers must enforce tenant and
539diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go
540index a06d268..caf5ad1 100644
541--- a/internal/gitrelay/gitrelay.go
542+++ b/internal/gitrelay/gitrelay.go
543@@ -47,11 +47,32 @@ type Repository struct {
544 Alternative bool
545 }
546
547+// Maintainer is one key that may sign repository state, push refs and change
548+// status. Role is "owner", "maintainer" for a key in the announcement's
549+// maintainers tag, or "agent" for a key the host vouches for under an agent
550+// grant; Name carries the grant's label when it has one.
551+type Maintainer struct {
552+ PubKey string `json:"pubkey"`
553+ Role string `json:"role"`
554+ Name string `json:"name,omitempty"`
555+}
556+
557+// MaintainerSource answers whether a key maintains a repository beyond the
558+// owner and the announcement's maintainers tag. The host relay supplies it so
559+// authority it records elsewhere, such as an agent grant, counts in every
560+// place this package checks maintainers.
561+type MaintainerSource interface {
562+ IsMaintainer(ctx context.Context, r Repository, pubkey string) bool
563+}
564+
565 type Config struct {
566 Store *storage.Store
567 Root string
568 Policy func() policy.Policy
569 Authorize func(context.Context, event.Event, Repository) error
570+ // Maintainers extends maintainer checks with host-side authority. It is
571+ // consulted only after the owner and the maintainers tag.
572+ Maintainers MaintainerSource
573 // AuthorizeHTTP is called for private repositories and for operators that
574 // want signed/NIP-98 authorization at the Git HTTP boundary.
575 AuthorizeHTTP func(context.Context, *http.Request, Repository) error
576@@ -94,6 +115,7 @@ type GitRelay struct {
577 policy func() policy.Policy
578 authorize func(context.Context, event.Event, Repository) error
579 authorizeHTTP func(context.Context, *http.Request, Repository) error
580+ maintainers MaintainerSource
581 serviceURL string
582 gitSync func(context.Context, Repository) error
583 eventSync func(context.Context, Repository) error
584@@ -385,7 +407,7 @@ func New(cfg Config) (*GitRelay, error) {
585 if serviceURL == "" {
586 serviceURL = cfg.ServiceURL
587 }
588- g := &GitRelay{store: cfg.Store, root: cfg.Root, policy: p, authorize: cfg.Authorize, authorizeHTTP: cfg.AuthorizeHTTP, serviceURL: strings.TrimRight(serviceURL, "/"), grasp06: cfg.EnableGRASP06, allowMissing: cfg.AllowMissingObjects, allowPrivate: cfg.AllowPrivateRelays, privatePeers: append([]string(nil), cfg.PrivatePeers...), httpAuth: cfg.HTTPAuth, gitSync: cfg.GitSync, eventSync: cfg.EventSync, onPromote: cfg.OnPromote, repos: make(map[string]Repository), pending: make(map[string]struct{})}
589+ g := &GitRelay{store: cfg.Store, root: cfg.Root, policy: p, authorize: cfg.Authorize, authorizeHTTP: cfg.AuthorizeHTTP, maintainers: cfg.Maintainers, serviceURL: strings.TrimRight(serviceURL, "/"), grasp06: cfg.EnableGRASP06, allowMissing: cfg.AllowMissingObjects, allowPrivate: cfg.AllowPrivateRelays, privatePeers: append([]string(nil), cfg.PrivatePeers...), httpAuth: cfg.HTTPAuth, gitSync: cfg.GitSync, eventSync: cfg.EventSync, onPromote: cfg.OnPromote, repos: make(map[string]Repository), pending: make(map[string]struct{})}
590 if err := g.recoverJournals(); err != nil {
591 return nil, err
592 }
593@@ -397,6 +419,20 @@ func New(cfg Config) (*GitRelay, error) {
594
595 func key(owner, identifier string) string { return owner + "\x00" + identifier }
596
597+// IsMaintainer reports whether pubkey may sign state and push refs for a
598+// repository: the owner, a key in the announcement's maintainers tag, or a key
599+// the configured MaintainerSource vouches for. Every maintainer check in this
600+// package goes through here so the host's answer applies uniformly.
601+func (g *GitRelay) IsMaintainer(ctx context.Context, r Repository, pubkey string) bool {
602+ if pubkey == "" {
603+ return false
604+ }
605+ if pubkey == r.Owner || contains(r.Maintainers, pubkey) {
606+ return true
607+ }
608+ return g.maintainers != nil && g.maintainers.IsMaintainer(ctx, r, pubkey)
609+}
610+
611 func (g *GitRelay) journalDir() string { return filepath.Join(g.root, ".tinyrelay", "journal") }
612
613 func (g *GitRelay) journalPath(r journalRecord) string {
614@@ -542,7 +578,7 @@ func (g *GitRelay) Validate(ctx context.Context, e event.Event) (Repository, err
615 if err := g.authorize(ctx, e, r); err != nil {
616 return Repository{}, err
617 }
618- } else if e.PubKey != r.Owner && !contains(r.Maintainers, e.PubKey) {
619+ } else if !g.IsMaintainer(ctx, r, e.PubKey) {
620 return Repository{}, errors.New("blocked: repository metadata must be signed by owner")
621 }
622 if e.Kind == 30618 {
623@@ -881,7 +917,7 @@ func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
624 }
625 for _, candidate := range q.Events {
626 ann, err = g.parseRepository(candidate)
627- if err == nil && ann.Owner == e.PubKey || err == nil && contains(ann.Maintainers, e.PubKey) {
628+ if err == nil && g.IsMaintainer(context.Background(), ann, e.PubKey) {
629 break
630 }
631 ann = Repository{}
632@@ -890,7 +926,7 @@ func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
633 return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")
634 }
635 }
636- if e.PubKey != ann.Owner && !contains(ann.Maintainers, e.PubKey) {
637+ if !g.IsMaintainer(context.Background(), ann, e.PubKey) {
638 return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")
639 }
640 state.Owner, state.Private = ann.Owner, ann.Private
641@@ -1836,11 +1872,18 @@ func (g *GitRelay) lookup(owner, id string) (Repository, error) {
642 // The event store may acknowledge a state before the asynchronous Git
643 // work intent has run. Load the latest signed state here so an immediate
644 // receive-pack request can stage its authorization hook synchronously.
645- states, stateErr := g.store.Query(context.Background(), event.Filter{Authors: []string{owner}, Kinds: []int{30618}, Tags: map[string][]string{"d": {id}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})
646- if stateErr == nil && len(states.Events) > 0 {
647- if state, parseErr := g.parseRepository(states.Events[0]); parseErr == nil {
648+ // A maintainer, including an agent the host vouches for, may have signed
649+ // it; parseRepository rejects any author who is not one.
650+ states, stateErr := g.store.Query(context.Background(), event.Filter{Kinds: []int{30618}, Tags: map[string][]string{"d": {id}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 8})
651+ if stateErr == nil {
652+ for _, candidate := range states.Events {
653+ state, parseErr := g.parseRepository(candidate)
654+ if parseErr != nil || state.Owner != owner {
655+ continue
656+ }
657 state.Clone, state.Relays, state.Maintainers = r.Clone, r.Relays, r.Maintainers
658 r = state
659+ break
660 }
661 }
662 g.mu.Lock()
663diff --git a/internal/gitrelay/maintainers_test.go b/internal/gitrelay/maintainers_test.go
664new file mode 100644
665index 0000000..e5a8eef
666--- /dev/null
667+++ b/internal/gitrelay/maintainers_test.go
668@@ -0,0 +1,76 @@
669+package gitrelay
670+
671+import (
672+ "context"
673+ "path/filepath"
674+ "strings"
675+ "testing"
676+
677+ "github.com/FelineStateMachine/tinyrelay/internal/event"
678+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
679+)
680+
681+type maintainerFunc func(context.Context, Repository, string) bool
682+
683+func (f maintainerFunc) IsMaintainer(ctx context.Context, r Repository, pubkey string) bool {
684+ return f(ctx, r, pubkey)
685+}
686+
687+// The host's maintainer answer is consulted after the owner and the
688+// maintainers tag, and it decides whether a state event from another key is
689+// accepted.
690+func TestMaintainerSourceDecidesStateAuthority(t *testing.T) {
691+ root := t.TempDir()
692+ store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
693+ if err != nil {
694+ t.Fatal(err)
695+ }
696+ defer store.Close()
697+ vouched := map[string]bool{}
698+ var asked []string
699+ g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Maintainers: maintainerFunc(func(_ context.Context, r Repository, pubkey string) bool {
700+ asked = append(asked, r.Identifier+":"+pubkey)
701+ return vouched[pubkey]
702+ })})
703+ if err != nil {
704+ t.Fatal(err)
705+ }
706+ ownerSecret, agentSecret := strings.Repeat("0", 63)+"1", strings.Repeat("0", 63)+"2"
707+ owner, _ := event.PublicKey(ownerSecret)
708+ agent, _ := event.PublicKey(agentSecret)
709+ announcement := event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}, {"clone", "https://relay.example/" + owner + "/demo.git"}}}
710+ if err := event.Sign(&announcement, ownerSecret); err != nil {
711+ t.Fatal(err)
712+ }
713+ if err := g.Publish(context.Background(), announcement); err != nil {
714+ t.Fatal(err)
715+ }
716+ state := func(createdAt int64) event.Event {
717+ e := event.Event{Kind: 30618, CreatedAt: createdAt, Tags: [][]string{{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", strings.Repeat("a", 40)}}}
718+ if err := event.Sign(&e, agentSecret); err != nil {
719+ t.Fatal(err)
720+ }
721+ return e
722+ }
723+ if _, err := g.Validate(context.Background(), state(101)); err == nil || !strings.Contains(err.Error(), "not an accepted repository maintainer") {
724+ t.Fatalf("state from an unvouched key: %v", err)
725+ }
726+ if len(asked) == 0 || asked[0] != "demo:"+agent {
727+ t.Fatalf("source was not consulted for the repository: %v", asked)
728+ }
729+ vouched[agent] = true
730+ repo, err := g.Validate(context.Background(), state(102))
731+ if err != nil {
732+ t.Fatalf("state from a vouched key: %v", err)
733+ }
734+ if repo.Owner != owner || repo.Refs["refs/heads/main"] != strings.Repeat("a", 40) {
735+ t.Fatalf("state bound to the wrong repository: %+v", repo)
736+ }
737+ if !g.IsMaintainer(context.Background(), repo, owner) || g.IsMaintainer(context.Background(), repo, "") {
738+ t.Fatal("owner must count and an empty key must not")
739+ }
740+ vouched[agent] = false
741+ if _, err := g.Validate(context.Background(), state(103)); err == nil {
742+ t.Fatal("a withdrawn answer must reject the next state")
743+ }
744+}
745diff --git a/internal/webui/collaboration_test.go b/internal/webui/collaboration_test.go
746index e3da3c3..0a76f9d 100644
747--- a/internal/webui/collaboration_test.go
748+++ b/internal/webui/collaboration_test.go
749@@ -48,7 +48,7 @@ func (b *collaborationBackend) Query(ctx context.Context, method string, params
750 return map[string]any{"items": []item{root}}, nil
751 }
752 if method == "browseissue" {
753- return map[string]any{"item": root, "replies": []any{}, "can_status": true, "repository": map[string]any{"owner": b.policy.Owner, "clone": []string{"https://git.example/test.git"}, "private": true}}, nil
754+ return map[string]any{"item": root, "replies": []any{}, "can_status": true, "repository": map[string]any{"owner": b.policy.Owner, "clone": []string{"https://git.example/test.git"}, "private": true, "maintainers": []map[string]any{{"pubkey": b.policy.Owner, "role": "owner"}, {"pubkey": strings.Repeat("c", 64), "role": "maintainer"}, {"pubkey": strings.Repeat("d", 64), "role": "agent", "name": "release-notes"}}}}, nil
755 }
756 return map[string]any{}, nil
757 }
758@@ -74,6 +74,9 @@ func TestCollaborationPagesUseTypedResultsAndWorkingDetailLinks(t *testing.T) {
759 if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Issue body") || !strings.Contains(w.Body.String(), `kind="status"`) || !strings.Contains(w.Body.String(), "https://git.example/test.git") || !strings.Contains(w.Body.String(), "<td>private</td>") {
760 t.Fatalf("detail failed: %d %s", w.Code, w.Body.String())
761 }
762+ if body := w.Body.String(); !strings.Contains(body, `<ul id="maintainers">`) || !strings.Contains(body, `hex="`+strings.Repeat("d", 64)+`"`) || !strings.Contains(body, "<small>agent release-notes</small>") || strings.Count(body, "<small>owner</small>") != 1 || strings.Contains(body, "<small>maintainer</small>") {
763+ t.Fatalf("maintainer panel: %s", body)
764+ }
765 var request map[string]any
766 if err := json.Unmarshal(backend.params[0], &request); err != nil {
767 t.Fatal(err)
768diff --git a/internal/webui/page.html b/internal/webui/page.html
769index ba01645..9fff089 100644
770--- a/internal/webui/page.html
771+++ b/internal/webui/page.html
772@@ -132,7 +132,8 @@
773 {{if and .Actor (eq .Tab "inbox")}}<section><h4>Notifications</h4><push-toggle></push-toggle><p><small>Relay notices for this key, on this device only.</small></p>{{if .Owner}}<rpc-form method="notifytest"><button>Send a test</button></rpc-form>{{end}}<noscript><p>Enabling notifications needs JavaScript.</p></noscript></section>{{end}}{{end}}
774
775 {{define "panel-repo"}}{{$r := asMap .Event}}{{with index $r "repository"}}{{$r = asMap .}}{{end}}<section><h4>About</h4><table><tr><th>owner</th><td>{{template "nostrKey" (str (index $r "owner"))}}</td></tr><tr><th>visibility</th><td>{{if index $r "private"}}private{{else}}public{{end}}</td></tr>{{with index $r "commit"}}<tr><th>head</th><td><code title="{{.}}">{{shortID .}}</code></td></tr>{{end}}</table></section>
776-<section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>
777+{{with index $r "maintainers"}}<section><h4>Maintainers</h4><ul id="maintainers">{{range .}}{{$m := asMap .}}<li>{{template "nostrKey" (str (index $m "pubkey"))}}{{$role := str (index $m "role")}}{{if eq $role "agent"}} <small>agent{{with str (index $m "name")}} {{.}}{{end}}</small>{{else if eq $role "owner"}} <small>owner</small>{{end}}</li>{{end}}</ul></section>
778+{{end}}<section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>
779 {{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="/repo"><input type="hidden" name="owner" value="{{$.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{$.Query.Get "repo"}}"><input type="hidden" name="path" value="{{$.Query.Get "path"}}"><input type="hidden" name="id" value="{{$.Query.Get "id"}}"><input type="hidden" name="view" value="{{repoView $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>go</button></form></section>{{end}}{{end}}
780
781 {{define "panel-manage"}}<section><h4>Policy</h4><table><tr><th>name</th><td>{{.Policy.Name}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>owner</th><td>{{template "nostrKey" .Policy.Owner}}</td></tr></table></section>

About

ownerc01503e8035f
visibilitypublic
heada5b9173eb7d5

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs