| 1 | diff --git a/docs/agents.md b/docs/agents.md |
| 2 | index 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 | |
| 23 | diff --git a/docs/git-collaboration.md b/docs/git-collaboration.md |
| 24 | index 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 | |
| 36 | diff --git a/internal/community/agents.go b/internal/community/agents.go |
| 37 | index 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 |
| 88 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 89 | index 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 | } |
| 109 | diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go |
| 110 | index 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 | } |
| 186 | diff --git a/internal/daemon/git_agent_maintainer_test.go b/internal/daemon/git_agent_maintainer_test.go |
| 187 | new file mode 100644 |
| 188 | index 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 | +} |
| 379 | diff --git a/internal/daemon/repository_maintainers.go b/internal/daemon/repository_maintainers.go |
| 380 | new file mode 100644 |
| 381 | index 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 | +} |
| 470 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 471 | index 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") |
| 492 | diff --git a/internal/gitrelay/browse.go b/internal/gitrelay/browse.go |
| 493 | index 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 |
| 539 | diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go |
| 540 | index 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() |
| 663 | diff --git a/internal/gitrelay/maintainers_test.go b/internal/gitrelay/maintainers_test.go |
| 664 | new file mode 100644 |
| 665 | index 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 | +} |
| 745 | diff --git a/internal/webui/collaboration_test.go b/internal/webui/collaboration_test.go |
| 746 | index 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) |
| 768 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 769 | index 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> |