- commit
- a5b9173eb7d5065bf5fef5d53b1533f2af36c062
- parent
- 394c96f73b50f578275fb273bc2020c8f03c5f56
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 4 | +- |
| M | docs/ | 2 | +- |
| M | internal/ | 34 | +++++ |
| M | internal/ | 3 | +- |
| M | internal/ | 19 | ++- |
| A | internal/ | 187 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 85 | ++++++++++++++ |
| M | internal/ | 4 | +- |
| M | internal/ | 36 | +++--- |
| M | internal/ | 57 | ++++++++- |
| A | internal/ | 76 | ++++++++++++ |
| M | internal/ | 5 | +- |
| M | internal/ | 3 | +- |
@@ -59,6 +59,8 @@ Every event from an agent key passes these checks before it is stored, whether i
5959 - 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`.6060 - The agent has not exceeded its per-minute rate.6161 62+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.63+6264 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.6365 6466 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.@@ -85,7 +87,7 @@ Either way the agent loses its role at once. A fresh grant restores access.
8587 8688 ## Manage > Agents8789 88-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.90+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.8991 9092 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.9193 @@ -6,7 +6,7 @@ Open a repository and choose **Issues** or **Pull requests**. Search titles and
66 77 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.88 9-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.9+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.1010 1111 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.1212 @@ -80,6 +80,12 @@ func (g AgentGrant) Active(now int64) bool {
8080 return !g.Paused && g.RevokedAt == 0 && g.ExpiresAt > now8181 }8282 83+// Maintains reports whether the grant currently makes the agent a maintainer84+// of one repository: it is active and holds maintain on that exact repository.85+func (g AgentGrant) Maintains(owner, identifier string, now int64) bool {86+ return g.Active(now) && g.RepoLevel(owner, identifier) == "maintain"87+}88+8389 // AllowsKind reports whether the agent may publish this kind. Profiles, relay8490 // lists and auth are always allowed so an agent can identify itself.8591 func (g AgentGrant) AllowsKind(kind int) bool {@@ -444,6 +450,34 @@ func (s *Service) Agents(ctx context.Context) ([]AgentSummary, error) {
444450 return out, rows.Err()445451 }446452 453+// RepositoryAgents lists the active grants that hold maintain on one454+// repository, in name order. Paused, revoked and expired grants are left out.455+func (s *Service) RepositoryAgents(ctx context.Context, owner, identifier string, now int64) ([]AgentGrant, error) {456+ 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)457+ if err != nil {458+ if strings.Contains(err.Error(), "no such table") {459+ return nil, nil460+ }461+ return nil, fmt.Errorf("list repository agents: %w", err)462+ }463+ defer rows.Close()464+ var out []AgentGrant465+ for rows.Next() {466+ var grant AgentGrant467+ var scope string468+ if err := rows.Scan(&grant.Agent, &grant.Owner, &grant.EventID, &grant.Name, &grant.ExpiresAt, &scope); err != nil {469+ return nil, err470+ }471+ if err := json.Unmarshal([]byte(scope), &grant.Scope); err != nil {472+ return nil, fmt.Errorf("agent grant scope: %w", err)473+ }474+ if grant.Maintains(owner, identifier, now) {475+ out = append(out, grant)476+ }477+ }478+ return out, rows.Err()479+}480+447481 // ActiveAgentCount counts grants that currently allow publishing.448482 func (s *Service) ActiveAgentCount(ctx context.Context, now int64) (int, error) {449483 var count int@@ -122,7 +122,7 @@ func (t *Tenant) browseRepoAccess(ctx context.Context, actor string, r gitrelay.
122122 if err != nil {123123 return err124124 }125- if actor == r.Owner || role == "owner" || role == "moderator" || role == "member" {125+ if role == "owner" || role == "moderator" || role == "member" || t.IsMaintainer(ctx, r, actor) {126126 return nil127127 }128128 return errors.New("restricted: private repository membership required")@@ -168,6 +168,7 @@ func (t *Tenant) browseRepo(ctx context.Context, actor string, q clientBrowseReq
168168 return nil, err169169 }170170 page.Private = page.Private || t.PrivateServiceEnabled()171+ page.Maintainers = t.repositoryMaintainers(ctx, r)171172 if q.View != "activity" {172173 return page, nil173174 }@@ -40,8 +40,8 @@ type collaborationDetail struct {
4040 ReplyCursor string `json:"reply_cursor,omitempty"`4141 }4242 43-func collaborationRepository(repo gitrelay.Repository) map[string]any {44- return map[string]any{"owner": repo.Owner, "private": repo.Private, "clone": repo.Clone, "refs": repo.Refs, "head": repo.Head}43+func (t *Tenant) collaborationRepository(ctx context.Context, repo gitrelay.Repository) map[string]any {44+ return map[string]any{"owner": repo.Owner, "private": repo.Private, "clone": repo.Clone, "refs": repo.Refs, "head": repo.Head, "maintainers": t.repositoryMaintainers(ctx, repo)}4545 }4646 4747 func (t *Tenant) collaborationRepo(ctx context.Context, actor string, q clientBrowseRequest) (gitrelay.Repository, error) {@@ -116,7 +116,7 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
116116 continue117117 }118118 if len(items) == q.Limit {119- return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": next}, nil119+ return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next}, nil120120 }121121 item := collaborationItemFrom(root)122122 item.Status = status@@ -124,13 +124,13 @@ func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q client
124124 next = collaborationCursor(item)125125 }126126 if !more {127- return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": ""}, nil127+ return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": ""}, nil128128 }129129 }130130 if cursor != nil {131131 next = strconv.FormatInt(cursor.CreatedAt, 10) + ":" + cursor.ID132132 }133- return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": next}, nil133+ return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next}, nil134134 }135135 136136 func parseCollaborationCursor(raw string) (*storage.EventCursor, error) {@@ -173,7 +173,7 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
173173 return nil, errors.New("not found: collaboration event")174174 }175175 root := rows[0]176- detail := collaborationDetail{Repository: collaborationRepository(r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor == root.PubKey || actor == r.Owner || contains(r.Maintainers, actor)}176+ detail := collaborationDetail{Repository: t.collaborationRepository(ctx, r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor != "" && (actor == root.PubKey || t.IsMaintainer(ctx, r, actor))}177177 replyCursor, err := parseCollaborationCursor(q.Cursor)178178 if err != nil {179179 return nil, err@@ -182,7 +182,7 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
182182 if replyLimit <= 0 || replyLimit > 200 {183183 replyLimit = 200184184 }185- authors := append([]string{root.PubKey, r.Owner}, r.Maintainers...)185+ authors := append([]string{root.PubKey}, t.maintainerKeys(ctx, r)...)186186 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))187187 if err != nil {188188 return nil, err@@ -281,7 +281,8 @@ func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots
281281 return statuses, nil282282 }283283 ids := make([]string, 0, len(roots))284- authors := append([]string{r.Owner}, r.Maintainers...)284+ maintainers := t.maintainerKeys(ctx, r)285+ authors := append([]string(nil), maintainers...)285286 for _, root := range roots {286287 ids = append(ids, root.ID)287288 authors = append(authors, root.PubKey)@@ -300,7 +301,7 @@ func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots
300301 for _, root := range roots {301302 allowed := []event.Event{}302303 for _, row := range byRoot[root.ID] {303- if row.PubKey == root.PubKey || row.PubKey == r.Owner || contains(r.Maintainers, row.PubKey) {304+ if row.PubKey == root.PubKey || contains(maintainers, row.PubKey) {304305 allowed = append(allowed, row)305306 }306307 }@@ -0,0 +1,187 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http/httptest"7+ "os/exec"8+ "path/filepath"9+ "strings"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/event"14+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"15+)16+17+const testOwnerNPub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"18+19+// agentTestRepo announces one repository from the test owner and returns a20+// working clone with one commit on main, plus that commit's id.21+func agentTestRepo(t *testing.T, tenant *Tenant, identifier string) (string, string) {22+ t.Helper()23+ now := time.Now().Unix()24+ announcement := signedEvent(t, testOwnerSecret, event.KIND_REPO, now, [][]string{{"d", identifier}, {"clone", "http://relay.test/" + testOwnerNPub + "/" + identifier + ".git"}, {"relays", "ws://relay.test"}}, "")25+ if err := publishAs(t, tenant, announcement); err != nil {26+ t.Fatalf("announcement rejected: %v", err)27+ }28+ source := filepath.Join(t.TempDir(), "source.git")29+ gitTest(t, "init", "--bare", source)30+ work := filepath.Join(t.TempDir(), "work")31+ gitTest(t, "clone", source, work)32+ gitTest(t, "-C", work, "config", "user.email", "test@example.com")33+ gitTest(t, "-C", work, "config", "user.name", "Test")34+ if err := osWrite(filepath.Join(work, "README"), []byte("agent\n")); err != nil {35+ t.Fatal(err)36+ }37+ gitTest(t, "-C", work, "add", "README")38+ gitTest(t, "-C", work, "commit", "-m", "first")39+ gitTest(t, "-C", work, "branch", "-M", "main")40+ return work, strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))41+}42+43+func agentStateEvent(t *testing.T, identifier, sha string, createdAt int64) event.Event {44+ t.Helper()45+ return signedEvent(t, testAgentSecret, event.KIND_REPO_STATE, createdAt, [][]string{{"d", identifier}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "")46+}47+48+func waitForRef(t *testing.T, tenant *Tenant, owner, identifier, ref, sha string) {49+ t.Helper()50+ deadline := time.Now().Add(10 * time.Second)51+ for time.Now().Before(deadline) {52+ if r, err := tenant.git.BrowseRepository(owner, identifier); err == nil && r.Refs[ref] == sha {53+ return54+ }55+ time.Sleep(20 * time.Millisecond)56+ }57+ t.Fatalf("%s did not reach %s", ref, sha)58+}59+60+func TestAgentMaintainGrantMakesTheAgentARepositoryMaintainer(t *testing.T) {61+ ctx := context.Background()62+ app, tenant := testTenant(t)63+ p := tenant.Policy()64+ p.Features.Grasp = true65+ if err := tenant.applyPolicy(ctx, p); err != nil {66+ t.Fatal(err)67+ }68+ owner, _ := event.PublicKey(testOwnerSecret)69+ agent, _ := event.PublicKey(testAgentSecret)70+ now := time.Now().Unix()71+ work, sha := agentTestRepo(t, tenant, "agentrepo")72+ repo := gitrelay.Repository{Owner: owner, Identifier: "agentrepo"}73+ coordinate := "30617:" + owner + ":agentrepo"74+ grantWith := func(level string, createdAt int64) event.Event {75+ return agentGrantEvent(t, agent, createdAt, createdAt+3600, []string{"repo", owner + ":agentrepo:" + level}, []string{"k", "30618"}, []string{"k", "1632"})76+ }77+78+ // A read grant lets the agent publish, but it is not a maintainer.79+ if err := publishAs(t, tenant, grantWith("read", now)); err != nil {80+ t.Fatalf("read grant rejected: %v", err)81+ }82+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+1)); err == nil || !strings.Contains(err.Error(), "not an accepted repository maintainer") {83+ t.Fatalf("state under a read grant: %v", err)84+ }85+ if tenant.IsMaintainer(ctx, repo, agent) {86+ t.Fatal("read grant counted as maintainer")87+ }88+89+ // A maintain grant makes the agent a maintainer: its state is accepted90+ // and a push matching that state goes through.91+ if err := publishAs(t, tenant, grantWith("maintain", now+2)); err != nil {92+ t.Fatalf("maintain grant rejected: %v", err)93+ }94+ if !tenant.IsMaintainer(ctx, repo, agent) {95+ t.Fatal("maintain grant did not count as maintainer")96+ }97+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+3)); err != nil {98+ t.Fatalf("state under a maintain grant rejected: %v", err)99+ }100+ server := httptest.NewServer(app)101+ defer server.Close()102+ url := server.URL + "/r/main/" + testOwnerNPub + "/agentrepo.git"103+ gitTest(t, "-C", work, "push", url, "refs/heads/main:refs/heads/main")104+ waitForRef(t, tenant, owner, "agentrepo", "refs/heads/main", sha)105+106+ // Browse output names the agent as a maintainer with its grant name.107+ raw, _ := json.Marshal(map[string]any{"owner": owner, "repo": "agentrepo", "view": "tree"})108+ result, err := tenant.Execute(ctx, "", "browserepo", []json.RawMessage{raw})109+ if err != nil {110+ t.Fatalf("browserepo: %v", err)111+ }112+ page, ok := result.(gitrelay.BrowsePage)113+ if !ok {114+ t.Fatalf("browserepo result %T", result)115+ }116+ 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"}) {117+ t.Fatalf("maintainers = %+v", page.Maintainers)118+ }119+120+ // The agent's status change counts on an issue it did not open.121+ issue := signedEvent(t, testOwnerSecret, event.KIND_GIT_ISSUE, now+4, [][]string{{"a", coordinate}, {"p", owner}, {"subject", "Bug"}}, "Details")122+ if err := publishAs(t, tenant, issue); err != nil {123+ t.Fatalf("issue rejected: %v", err)124+ }125+ closed := signedEvent(t, testAgentSecret, 1632, now+5, [][]string{{"a", coordinate}, {"e", issue.ID, "", "root"}, {"p", owner}}, "")126+ if err := publishAs(t, tenant, closed); err != nil {127+ t.Fatalf("agent status rejected: %v", err)128+ }129+ raw, _ = json.Marshal(map[string]any{"owner": owner, "repo": "agentrepo", "event": issue.ID})130+ result, err = tenant.Execute(ctx, agent, "browseissue", []json.RawMessage{raw})131+ if err != nil {132+ t.Fatalf("browseissue: %v", err)133+ }134+ detail := result.(collaborationDetail)135+ if detail.Item.Status != "closed" || !detail.CanStatus {136+ t.Fatalf("status = %q can_status = %v, want closed by the agent maintainer", detail.Item.Status, detail.CanStatus)137+ }138+ maintainers, _ := detail.Repository["maintainers"].([]gitrelay.Maintainer)139+ if len(maintainers) != 2 || maintainers[1].Role != "agent" {140+ t.Fatalf("detail maintainers = %+v", maintainers)141+ }142+143+ // A second commit needs a new state; a paused grant signs nothing that144+ // counts, so the push is refused by the signed-ref hook.145+ if err := osWrite(filepath.Join(work, "README"), []byte("agent again\n")); err != nil {146+ t.Fatal(err)147+ }148+ gitTest(t, "-C", work, "commit", "-am", "second")149+ next := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))150+ if _, err := tenant.community.SetAgentPaused(ctx, owner, agent, true); err != nil {151+ t.Fatal(err)152+ }153+ if tenant.IsMaintainer(ctx, repo, agent) {154+ t.Fatal("paused grant counted as maintainer")155+ }156+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", next, now+6)); err == nil || !strings.Contains(err.Error(), "paused") {157+ t.Fatalf("state under a paused grant: %v", err)158+ }159+ if out, err := exec.Command("git", "-C", work, "push", url, "refs/heads/main:refs/heads/main").CombinedOutput(); err == nil {160+ t.Fatalf("push without a matching signed state succeeded: %s", out)161+ }162+ if page, _ := tenant.Execute(ctx, "", "browserepo", []json.RawMessage{raw}); len(page.(gitrelay.BrowsePage).Maintainers) != 1 {163+ t.Fatalf("paused agent still listed: %+v", page.(gitrelay.BrowsePage).Maintainers)164+ }165+166+ // Resumed, the agent maintains again; revoked, it never does.167+ if _, err := tenant.community.SetAgentPaused(ctx, owner, agent, false); err != nil {168+ t.Fatal(err)169+ }170+ if !tenant.IsMaintainer(ctx, repo, agent) {171+ t.Fatal("resumed grant did not count as maintainer")172+ }173+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", next, now+7)); err != nil {174+ t.Fatalf("state after resume rejected: %v", err)175+ }176+ gitTest(t, "-C", work, "push", url, "refs/heads/main:refs/heads/main")177+ waitForRef(t, tenant, owner, "agentrepo", "refs/heads/main", next)178+ if _, err := tenant.community.RevokeAgent(ctx, owner, agent, now+8); err != nil {179+ t.Fatal(err)180+ }181+ if tenant.IsMaintainer(ctx, repo, agent) {182+ t.Fatal("revoked grant counted as maintainer")183+ }184+ if err := publishAs(t, tenant, agentStateEvent(t, "agentrepo", sha, now+9)); err == nil || !strings.Contains(err.Error(), "revoked") {185+ t.Fatalf("state under a revoked grant: %v", err)186+ }187+}@@ -0,0 +1,85 @@
1+package daemon2+3+import (4+ "context"5+ "time"6+7+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"8+)9+10+// Repository maintainers. The owner and the keys in an announcement's11+// maintainers tag are maintainers by the event itself; an agent becomes one12+// through a kind 30392 grant that holds maintain on that exact repository.13+// This file is the one place that answers "does this key maintain this14+// repository", for state events and pushes through gitrelay, for status15+// changes and access checks in the daemon, and for the browse output that16+// the repository panel lists.17+18+// IsMaintainer implements gitrelay.MaintainerSource. A paused, revoked or19+// expired grant never counts. Outcomes are recorded under the authorize20+// operation; the key itself is never logged or labelled.21+func (t *Tenant) IsMaintainer(ctx context.Context, r gitrelay.Repository, pubkey string) bool {22+ if pubkey == "" {23+ return false24+ }25+ if pubkey == r.Owner || contains(r.Maintainers, pubkey) {26+ return true27+ }28+ ctx, finish := t.app.telemetry.Start(ctx, "authorize")29+ outcome := "unauthorized"30+ defer func() { finish(outcome) }()31+ grant, ok, err := t.community.AgentGrant(ctx, pubkey)32+ if err != nil {33+ outcome = "error"34+ t.app.telemetry.Logger().WarnContext(ctx, "agent maintainer lookup failed", "error", err)35+ return false36+ }37+ if ok && grant.Maintains(r.Owner, r.Identifier, time.Now().Unix()) {38+ outcome = "ok"39+ return true40+ }41+ return false42+}43+44+// repositoryMaintainers lists the owner, the announcement's maintainers and45+// every agent whose active grant holds maintain on the repository, in that46+// order. A key in the maintainers tag that also holds such a grant is47+// reported as an agent so the interface can say so.48+func (t *Tenant) repositoryMaintainers(ctx context.Context, r gitrelay.Repository) []gitrelay.Maintainer {49+ out := []gitrelay.Maintainer{{PubKey: r.Owner, Role: "owner"}}50+ seen := map[string]int{r.Owner: 0}51+ for _, pubkey := range r.Maintainers {52+ if _, dup := seen[pubkey]; dup {53+ continue54+ }55+ seen[pubkey] = len(out)56+ out = append(out, gitrelay.Maintainer{PubKey: pubkey, Role: "maintainer"})57+ }58+ grants, err := t.community.RepositoryAgents(ctx, r.Owner, r.Identifier, time.Now().Unix())59+ if err != nil {60+ t.app.telemetry.Logger().WarnContext(ctx, "repository agents lookup failed", "error", err)61+ return out62+ }63+ for _, grant := range grants {64+ if index, dup := seen[grant.Agent]; dup {65+ if index > 0 {66+ out[index].Role, out[index].Name = "agent", grant.Name67+ }68+ continue69+ }70+ seen[grant.Agent] = len(out)71+ out = append(out, gitrelay.Maintainer{PubKey: grant.Agent, Role: "agent", Name: grant.Name})72+ }73+ return out74+}75+76+// maintainerKeys returns the keys whose status events count for a77+// repository, for use as an authors filter.78+func (t *Tenant) maintainerKeys(ctx context.Context, r gitrelay.Repository) []string {79+ maintainers := t.repositoryMaintainers(ctx, r)80+ keys := make([]string, 0, len(maintainers))81+ for _, maintainer := range maintainers {82+ keys = append(keys, maintainer.PubKey)83+ }84+ return keys85+}@@ -118,7 +118,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
118118 if err := t.reconcileAutomaticInbox(ctx, currentPolicy, currentPolicy); err != nil {119119 return err120120 }121- 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 {121+ 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 {122122 return t.releaseGit(ctx, id)123123 }})124124 if err != nil {@@ -383,7 +383,7 @@ func (t *Tenant) authorizeGit(ctx context.Context, r *http.Request, repo gitrela
383383 if err := t.requirePrivateAccess(ctx, e.PubKey); err != nil {384384 return err385385 }386- } else if e.PubKey != repo.Owner {386+ } else if !t.IsMaintainer(ctx, repo, e.PubKey) {387387 role, roleErr := t.community.Role(ctx, e.PubKey)388388 if roleErr != nil || (role != "owner" && role != "moderator" && role != "member") {389389 return errors.New("restricted: repository authorization required")@@ -46,23 +46,25 @@ type BrowseCommit struct {
4646 }4747 4848 type BrowsePage struct {49- Owner string `json:"owner"`50- Identifier string `json:"identifier"`51- Head string `json:"head"`52- Private bool `json:"private"`53- Clone []string `json:"clone"`54- Relays []string `json:"relays"`55- Refs map[string]string `json:"refs"`56- Commit string `json:"commit"`57- Path string `json:"path"`58- View string `json:"view"`59- Entries []BrowseEntry `json:"entries"`60- Content string `json:"content"`61- Binary bool `json:"binary"`62- Truncated bool `json:"truncated"`63- Commits []BrowseCommit `json:"commits"`64- Diff string `json:"diff"`65- NextOffset int `json:"next_offset"`49+ Owner string `json:"owner"`50+ Identifier string `json:"identifier"`51+ Head string `json:"head"`52+ Private bool `json:"private"`53+ Clone []string `json:"clone"`54+ Relays []string `json:"relays"`55+ // Maintainers is filled by the host, which knows about agent grants.56+ Maintainers []Maintainer `json:"maintainers,omitempty"`57+ Refs map[string]string `json:"refs"`58+ Commit string `json:"commit"`59+ Path string `json:"path"`60+ View string `json:"view"`61+ Entries []BrowseEntry `json:"entries"`62+ Content string `json:"content"`63+ Binary bool `json:"binary"`64+ Truncated bool `json:"truncated"`65+ Commits []BrowseCommit `json:"commits"`66+ Diff string `json:"diff"`67+ NextOffset int `json:"next_offset"`6668 }6769 6870 // Repositories returns detached snapshots. Callers must enforce tenant and@@ -47,11 +47,32 @@ type Repository struct {
4747 Alternative bool4848 }4949 50+// Maintainer is one key that may sign repository state, push refs and change51+// status. Role is "owner", "maintainer" for a key in the announcement's52+// maintainers tag, or "agent" for a key the host vouches for under an agent53+// grant; Name carries the grant's label when it has one.54+type Maintainer struct {55+ PubKey string `json:"pubkey"`56+ Role string `json:"role"`57+ Name string `json:"name,omitempty"`58+}59+60+// MaintainerSource answers whether a key maintains a repository beyond the61+// owner and the announcement's maintainers tag. The host relay supplies it so62+// authority it records elsewhere, such as an agent grant, counts in every63+// place this package checks maintainers.64+type MaintainerSource interface {65+ IsMaintainer(ctx context.Context, r Repository, pubkey string) bool66+}67+5068 type Config struct {5169 Store *storage.Store5270 Root string5371 Policy func() policy.Policy5472 Authorize func(context.Context, event.Event, Repository) error73+ // Maintainers extends maintainer checks with host-side authority. It is74+ // consulted only after the owner and the maintainers tag.75+ Maintainers MaintainerSource5576 // AuthorizeHTTP is called for private repositories and for operators that5677 // want signed/NIP-98 authorization at the Git HTTP boundary.5778 AuthorizeHTTP func(context.Context, *http.Request, Repository) error@@ -94,6 +115,7 @@ type GitRelay struct {
94115 policy func() policy.Policy95116 authorize func(context.Context, event.Event, Repository) error96117 authorizeHTTP func(context.Context, *http.Request, Repository) error118+ maintainers MaintainerSource97119 serviceURL string98120 gitSync func(context.Context, Repository) error99121 eventSync func(context.Context, Repository) error@@ -385,7 +407,7 @@ func New(cfg Config) (*GitRelay, error) {
385407 if serviceURL == "" {386408 serviceURL = cfg.ServiceURL387409 }388- 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{})}410+ 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{})}389411 if err := g.recoverJournals(); err != nil {390412 return nil, err391413 }@@ -397,6 +419,20 @@ func New(cfg Config) (*GitRelay, error) {
397419 398420 func key(owner, identifier string) string { return owner + "\x00" + identifier }399421 422+// IsMaintainer reports whether pubkey may sign state and push refs for a423+// repository: the owner, a key in the announcement's maintainers tag, or a key424+// the configured MaintainerSource vouches for. Every maintainer check in this425+// package goes through here so the host's answer applies uniformly.426+func (g *GitRelay) IsMaintainer(ctx context.Context, r Repository, pubkey string) bool {427+ if pubkey == "" {428+ return false429+ }430+ if pubkey == r.Owner || contains(r.Maintainers, pubkey) {431+ return true432+ }433+ return g.maintainers != nil && g.maintainers.IsMaintainer(ctx, r, pubkey)434+}435+400436 func (g *GitRelay) journalDir() string { return filepath.Join(g.root, ".tinyrelay", "journal") }401437 402438 func (g *GitRelay) journalPath(r journalRecord) string {@@ -542,7 +578,7 @@ func (g *GitRelay) Validate(ctx context.Context, e event.Event) (Repository, err
542578 if err := g.authorize(ctx, e, r); err != nil {543579 return Repository{}, err544580 }545- } else if e.PubKey != r.Owner && !contains(r.Maintainers, e.PubKey) {581+ } else if !g.IsMaintainer(ctx, r, e.PubKey) {546582 return Repository{}, errors.New("blocked: repository metadata must be signed by owner")547583 }548584 if e.Kind == 30618 {@@ -881,7 +917,7 @@ func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
881917 }882918 for _, candidate := range q.Events {883919 ann, err = g.parseRepository(candidate)884- if err == nil && ann.Owner == e.PubKey || err == nil && contains(ann.Maintainers, e.PubKey) {920+ if err == nil && g.IsMaintainer(context.Background(), ann, e.PubKey) {885921 break886922 }887923 ann = Repository{}@@ -890,7 +926,7 @@ func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
890926 return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")891927 }892928 }893- if e.PubKey != ann.Owner && !contains(ann.Maintainers, e.PubKey) {929+ if !g.IsMaintainer(context.Background(), ann, e.PubKey) {894930 return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")895931 }896932 state.Owner, state.Private = ann.Owner, ann.Private@@ -1836,11 +1872,18 @@ func (g *GitRelay) lookup(owner, id string) (Repository, error) {
18361872 // The event store may acknowledge a state before the asynchronous Git18371873 // work intent has run. Load the latest signed state here so an immediate18381874 // receive-pack request can stage its authorization hook synchronously.1839- 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})1840- if stateErr == nil && len(states.Events) > 0 {1841- if state, parseErr := g.parseRepository(states.Events[0]); parseErr == nil {1875+ // A maintainer, including an agent the host vouches for, may have signed1876+ // it; parseRepository rejects any author who is not one.1877+ 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})1878+ if stateErr == nil {1879+ for _, candidate := range states.Events {1880+ state, parseErr := g.parseRepository(candidate)1881+ if parseErr != nil || state.Owner != owner {1882+ continue1883+ }18421884 state.Clone, state.Relays, state.Maintainers = r.Clone, r.Relays, r.Maintainers18431885 r = state1886+ break18441887 }18451888 }18461889 g.mu.Lock()@@ -0,0 +1,76 @@
1+package gitrelay2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/storage"11+)12+13+type maintainerFunc func(context.Context, Repository, string) bool14+15+func (f maintainerFunc) IsMaintainer(ctx context.Context, r Repository, pubkey string) bool {16+ return f(ctx, r, pubkey)17+}18+19+// The host's maintainer answer is consulted after the owner and the20+// maintainers tag, and it decides whether a state event from another key is21+// accepted.22+func TestMaintainerSourceDecidesStateAuthority(t *testing.T) {23+ root := t.TempDir()24+ store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))25+ if err != nil {26+ t.Fatal(err)27+ }28+ defer store.Close()29+ vouched := map[string]bool{}30+ var asked []string31+ g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Maintainers: maintainerFunc(func(_ context.Context, r Repository, pubkey string) bool {32+ asked = append(asked, r.Identifier+":"+pubkey)33+ return vouched[pubkey]34+ })})35+ if err != nil {36+ t.Fatal(err)37+ }38+ ownerSecret, agentSecret := strings.Repeat("0", 63)+"1", strings.Repeat("0", 63)+"2"39+ owner, _ := event.PublicKey(ownerSecret)40+ agent, _ := event.PublicKey(agentSecret)41+ announcement := event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}, {"clone", "https://relay.example/" + owner + "/demo.git"}}}42+ if err := event.Sign(&announcement, ownerSecret); err != nil {43+ t.Fatal(err)44+ }45+ if err := g.Publish(context.Background(), announcement); err != nil {46+ t.Fatal(err)47+ }48+ state := func(createdAt int64) event.Event {49+ e := event.Event{Kind: 30618, CreatedAt: createdAt, Tags: [][]string{{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", strings.Repeat("a", 40)}}}50+ if err := event.Sign(&e, agentSecret); err != nil {51+ t.Fatal(err)52+ }53+ return e54+ }55+ if _, err := g.Validate(context.Background(), state(101)); err == nil || !strings.Contains(err.Error(), "not an accepted repository maintainer") {56+ t.Fatalf("state from an unvouched key: %v", err)57+ }58+ if len(asked) == 0 || asked[0] != "demo:"+agent {59+ t.Fatalf("source was not consulted for the repository: %v", asked)60+ }61+ vouched[agent] = true62+ repo, err := g.Validate(context.Background(), state(102))63+ if err != nil {64+ t.Fatalf("state from a vouched key: %v", err)65+ }66+ if repo.Owner != owner || repo.Refs["refs/heads/main"] != strings.Repeat("a", 40) {67+ t.Fatalf("state bound to the wrong repository: %+v", repo)68+ }69+ if !g.IsMaintainer(context.Background(), repo, owner) || g.IsMaintainer(context.Background(), repo, "") {70+ t.Fatal("owner must count and an empty key must not")71+ }72+ vouched[agent] = false73+ if _, err := g.Validate(context.Background(), state(103)); err == nil {74+ t.Fatal("a withdrawn answer must reject the next state")75+ }76+}@@ -48,7 +48,7 @@ func (b *collaborationBackend) Query(ctx context.Context, method string, params
4848 return map[string]any{"items": []item{root}}, nil4949 }5050 if method == "browseissue" {51- 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}}, nil51+ 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"}}}}, nil5252 }5353 return map[string]any{}, nil5454 }@@ -74,6 +74,9 @@ func TestCollaborationPagesUseTypedResultsAndWorkingDetailLinks(t *testing.T) {
7474 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>") {7575 t.Fatalf("detail failed: %d %s", w.Code, w.Body.String())7676 }77+ 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>") {78+ t.Fatalf("maintainer panel: %s", body)79+ }7780 var request map[string]any7881 if err := json.Unmarshal(backend.params[0], &request); err != nil {7982 t.Fatal(err)@@ -132,7 +132,8 @@
132132 {{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}}133133 134134 {{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>135-<section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>135+{{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>136+{{end}}<section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>136137 {{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}}137138 138139 {{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>