clone

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

add agent grants and the agent role

Dami committed 568610d
commit
568610dff70a20c0f45dedb1e34677e2f5a76b37
parent
b9eb374694a3ad6d1e80435a0aa914bca051c8bd
author
Dami <dami.etoile@gmail.com>
4 files changed, +518 -5
Ainternal/community/agents.go504++++++++++++++++++++++++++++++
Minternal/community/community.go2+-
Minternal/event/kinds.go1+
Minternal/records/records.go16+-
A internal/community/agents.go +504 -0
@@ -0,0 +1,504 @@
1+package community2+3+// Agent identities. An agent is a member with role "agent" that publishes4+// under a grant signed by the owner or a moderator. The grant is a normal5+// addressable event (kind 30392) mirrored into agent_grants so the write gate6+// can answer "may this key publish this event" with one indexed lookup. The7+// agent stays the author of everything it publishes; the relay only decides8+// whether to keep it.9+10+import (11+ "context"12+ "database/sql"13+ "encoding/json"14+ "errors"15+ "fmt"16+ "strconv"17+ "strings"18+19+ "github.com/FelineStateMachine/tinyrelay/internal/event"20+)21+22+const (23+ AgentRateDefault = 6024+ AgentRateMax = 60025+ AgentGrantMaxDays = 36526+ agentNameMax = 6427+ agentRoomMax = 12828+ agentListMax = 25629+)30+31+const agentSchema = `CREATE TABLE IF NOT EXISTS agent_grants(agent TEXT PRIMARY KEY,owner TEXT NOT NULL,event_id TEXT NOT NULL,name TEXT NOT NULL DEFAULT '',expires_at INTEGER NOT NULL,paused INTEGER NOT NULL DEFAULT 0,revoked_at INTEGER NOT NULL DEFAULT 0,scope TEXT NOT NULL DEFAULT '{}');`32+33+// AgentRepo is one repository an agent may work in. Level is "read" or34+// "maintain"; maintain includes read.35+type AgentRepo struct {36+ Owner string `json:"owner"`37+ Identifier string `json:"identifier"`38+ Level string `json:"level"`39+}40+41+// AgentScope is the part of a grant the gate checks on every event.42+type AgentScope struct {43+ Kinds []int `json:"kinds"`44+ Rooms []string `json:"rooms"`45+ Repos []AgentRepo `json:"repos"`46+ Wiki string `json:"wiki,omitempty"`47+ Rate int `json:"rate"`48+}49+50+// AgentGrant mirrors one grant event plus the owner's pause and revoke marks.51+type AgentGrant struct {52+ Agent string `json:"pubkey"`53+ Owner string `json:"owner"`54+ EventID string `json:"event_id"`55+ Name string `json:"name"`56+ ExpiresAt int64 `json:"expires"`57+ Paused bool `json:"paused"`58+ RevokedAt int64 `json:"revoked"`59+ Scope AgentScope `json:"scope"`60+}61+62+// AgentSummary is the management view of a grant.63+type AgentSummary struct {64+ AgentGrant65+ LastEvent int64 `json:"lastEvent"`66+}67+68+// Active reports whether the grant currently lets the agent publish.69+func (g AgentGrant) Active(now int64) bool {70+ return !g.Paused && g.RevokedAt == 0 && g.ExpiresAt > now71+}72+73+// AllowsKind reports whether the agent may publish this kind. Profiles, relay74+// lists and auth are always allowed so an agent can identify itself.75+func (g AgentGrant) AllowsKind(kind int) bool {76+ if kind == event.KIND_PROFILE || kind == 10002 || kind == event.KIND_AUTH {77+ return true78+ }79+ for _, allowed := range g.Scope.Kinds {80+ if allowed == kind {81+ return true82+ }83+ }84+ return false85+}86+87+// AllowsRoom reports whether the agent may publish into a room.88+func (g AgentGrant) AllowsRoom(room string) bool {89+ for _, allowed := range g.Scope.Rooms {90+ if allowed == room {91+ return true92+ }93+ }94+ return false95+}96+97+// RepoLevel returns the agent's level in a repository, or "" when the98+// repository is outside the grant.99+func (g AgentGrant) RepoLevel(owner, identifier string) string {100+ for _, repo := range g.Scope.Repos {101+ if repo.Owner == owner && repo.Identifier == identifier {102+ return repo.Level103+ }104+ }105+ return ""106+}107+108+// Check applies the grant to one event and returns a NIP-01 rejection when109+// the grant does not cover it.110+func (g AgentGrant) Check(e event.Event, now int64) error {111+ switch {112+ case g.RevokedAt > 0:113+ return errors.New("restricted: agent grant is revoked")114+ case g.Paused:115+ return errors.New("restricted: agent grant is paused")116+ case g.ExpiresAt <= now:117+ return errors.New("restricted: agent grant has expired")118+ }119+ if !g.AllowsKind(e.Kind) {120+ return fmt.Errorf("restricted: agent grant does not allow kind %d", e.Kind)121+ }122+ if room := event.Tag(e, "h"); room != "" && !g.AllowsRoom(room) {123+ return errors.New("restricted: agent grant does not allow room " + room)124+ }125+ if !gitKind(e.Kind) {126+ return nil127+ }128+ coordinates := repositoryCoordinates(e)129+ if len(coordinates) == 0 {130+ if e.Kind == 1111 {131+ return nil132+ }133+ return errors.New("restricted: agent grant does not allow repository events without a repository address")134+ }135+ for _, coordinate := range coordinates {136+ level := g.RepoLevel(coordinate[0], coordinate[1])137+ if level == "" {138+ return fmt.Errorf("restricted: agent grant does not allow repository %s:%s", coordinate[0], coordinate[1])139+ }140+ if gitStatusKind(e.Kind) && level != "maintain" {141+ return fmt.Errorf("restricted: agent grant does not allow status changes in repository %s:%s", coordinate[0], coordinate[1])142+ }143+ }144+ return nil145+}146+147+func gitKind(kind int) bool {148+ return kind == event.KIND_GIT_PATCH || kind == event.KIND_GIT_PR || kind == event.KIND_GIT_PR_UPDATE || kind == event.KIND_GIT_ISSUE || kind == 1111 || gitStatusKind(kind)149+}150+151+func gitStatusKind(kind int) bool { return kind >= 1630 && kind <= 1633 }152+153+// repositoryCoordinates lists the 30617 owner and identifier pairs an event154+// points at through its a or A tags.155+func repositoryCoordinates(e event.Event) [][2]string {156+ var out [][2]string157+ for _, tag := range e.Tags {158+ if len(tag) < 2 || (tag[0] != "a" && tag[0] != "A") {159+ continue160+ }161+ parts := strings.SplitN(tag[1], ":", 3)162+ if len(parts) != 3 || parts[0] != strconv.Itoa(event.KIND_REPO) || !validPubKey(parts[1]) || parts[2] == "" {163+ continue164+ }165+ out = append(out, [2]string{parts[1], parts[2]})166+ }167+ return out168+}169+170+// ParseAgentGrant validates a kind 30392 event and returns the grant it171+// describes. Signer authority is checked by the caller.172+func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {173+ if e.Kind != event.KIND_AGENT_GRANT {174+ return AgentGrant{}, fmt.Errorf("invalid: kind %d is not an agent grant", e.Kind)175+ }176+ agent := event.Tag(e, "d")177+ if !validPubKey(agent) {178+ return AgentGrant{}, errors.New("invalid: agent grant needs the agent pubkey as its d tag")179+ }180+ if event.Tag(e, "p") != agent {181+ return AgentGrant{}, errors.New("invalid: agent grant p tag must match its d tag")182+ }183+ if agent == e.PubKey {184+ return AgentGrant{}, errors.New("invalid: agent grant may not name its signer")185+ }186+ expires := event.Expiration(e)187+ if expires == 0 {188+ return AgentGrant{}, errors.New("invalid: agent grant needs an expiration tag")189+ }190+ if expires > now+AgentGrantMaxDays*86400 {191+ return AgentGrant{}, fmt.Errorf("invalid: agent grant expiration is more than %d days out", AgentGrantMaxDays)192+ }193+ if e.Content != "" && !json.Valid([]byte(e.Content)) {194+ return AgentGrant{}, errors.New("invalid: agent grant content must be empty or JSON")195+ }196+ grant := AgentGrant{Agent: agent, Owner: e.PubKey, EventID: e.ID, ExpiresAt: expires, Scope: AgentScope{Kinds: []int{}, Rooms: []string{}, Repos: []AgentRepo{}, Rate: AgentRateDefault}}197+ grant.Name = strings.TrimSpace(event.Tag(e, "name"))198+ if len(grant.Name) > agentNameMax {199+ grant.Name = grant.Name[:agentNameMax]200+ }201+ seenRoom := map[string]bool{}202+ for _, room := range event.TagValues(e, "room") {203+ room = strings.TrimSpace(room)204+ if room == "" || len(room) > agentRoomMax {205+ return AgentGrant{}, errors.New("invalid: agent grant room tag")206+ }207+ if !seenRoom[room] {208+ seenRoom[room] = true209+ grant.Scope.Rooms = append(grant.Scope.Rooms, room)210+ }211+ }212+ for _, value := range event.TagValues(e, "repo") {213+ repo, ok := parseAgentRepo(value)214+ if !ok {215+ return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier:read or owner:identifier:maintain")216+ }217+ if grant.RepoLevel(repo.Owner, repo.Identifier) == "" {218+ grant.Scope.Repos = append(grant.Scope.Repos, repo)219+ }220+ }221+ seenKind := map[int]bool{}222+ for _, value := range event.TagValues(e, "k") {223+ kind, err := strconv.Atoi(strings.TrimSpace(value))224+ if err != nil || kind < 0 || kind > 65535 {225+ return AgentGrant{}, errors.New("invalid: agent grant k tag must be an event kind")226+ }227+ if !seenKind[kind] {228+ seenKind[kind] = true229+ grant.Scope.Kinds = append(grant.Scope.Kinds, kind)230+ }231+ }232+ if len(grant.Scope.Rooms) > agentListMax || len(grant.Scope.Repos) > agentListMax || len(grant.Scope.Kinds) > agentListMax {233+ return AgentGrant{}, fmt.Errorf("invalid: agent grant lists more than %d entries", agentListMax)234+ }235+ switch wiki := event.Tag(e, "wiki"); wiki {236+ case "", "propose", "edit":237+ grant.Scope.Wiki = wiki238+ default:239+ return AgentGrant{}, errors.New("invalid: agent grant wiki tag must be propose or edit")240+ }241+ if value := event.Tag(e, "rate"); value != "" {242+ rate, err := strconv.Atoi(strings.TrimSpace(value))243+ if err != nil || rate < 1 || rate > AgentRateMax {244+ return AgentGrant{}, fmt.Errorf("invalid: agent grant rate must be between 1 and %d events per minute", AgentRateMax)245+ }246+ grant.Scope.Rate = rate247+ }248+ return grant, nil249+}250+251+func parseAgentRepo(value string) (AgentRepo, bool) {252+ value = strings.TrimSpace(value)253+ last := strings.LastIndex(value, ":")254+ if last < 0 {255+ return AgentRepo{}, false256+ }257+ level := value[last+1:]258+ if level != "read" && level != "maintain" {259+ return AgentRepo{}, false260+ }261+ parts := strings.SplitN(value[:last], ":", 2)262+ if len(parts) != 2 || !validPubKey(parts[0]) || parts[1] == "" || len(parts[1]) > 256 {263+ return AgentRepo{}, false264+ }265+ return AgentRepo{Owner: parts[0], Identifier: parts[1], Level: level}, true266+}267+268+// CanGrantAgents reports whether a role may sign agent grants.269+func CanGrantAgents(role string) bool { return role == "owner" || role == "moderator" }270+271+// AgentGrant returns the grant recorded for a pubkey, if any.272+func (s *Service) AgentGrant(ctx context.Context, pubkey string) (AgentGrant, bool, error) {273+ var grant AgentGrant274+ var paused int275+ var scope string276+ err := s.store.DB().QueryRowContext(ctx, `SELECT agent,owner,event_id,name,expires_at,paused,revoked_at,scope FROM agent_grants WHERE agent=?`, pubkey).Scan(&grant.Agent, &grant.Owner, &grant.EventID, &grant.Name, &grant.ExpiresAt, &paused, &grant.RevokedAt, &scope)277+ if errors.Is(err, sql.ErrNoRows) {278+ return AgentGrant{}, false, nil279+ }280+ if err != nil {281+ return AgentGrant{}, false, fmt.Errorf("agent grant: %w", err)282+ }283+ grant.Paused = paused != 0284+ if err := json.Unmarshal([]byte(scope), &grant.Scope); err != nil {285+ return AgentGrant{}, false, fmt.Errorf("agent grant scope: %w", err)286+ }287+ return grant, true, nil288+}289+290+// ApplyAgentEventTx mirrors a grant or a deletion into agent_grants and the291+// member roster inside the transaction that stores the event, so the role and292+// the event land or roll back together.293+func (s *Service) ApplyAgentEventTx(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {294+ switch e.Kind {295+ case event.KIND_AGENT_GRANT:296+ grant, err := ParseAgentGrant(e, now)297+ if err != nil {298+ return err299+ }300+ return s.applyAgentGrantTx(ctx, tx, grant, now)301+ case event.KIND_DELETION:302+ return s.revokeDeletedGrantsTx(ctx, tx, e, now)303+ }304+ return nil305+}306+307+func (s *Service) applyAgentGrantTx(ctx context.Context, tx *sql.Tx, grant AgentGrant, now int64) error {308+ scope, err := json.Marshal(grant.Scope)309+ if err != nil {310+ return err311+ }312+ revokedAt := int64(0)313+ if grant.ExpiresAt <= now {314+ revokedAt = now315+ }316+ if _, err := tx.ExecContext(ctx, `INSERT INTO agent_grants(agent,owner,event_id,name,expires_at,paused,revoked_at,scope) VALUES(?,?,?,?,?,0,?,?) ON CONFLICT(agent) DO UPDATE SET owner=excluded.owner,event_id=excluded.event_id,name=excluded.name,expires_at=excluded.expires_at,paused=0,revoked_at=excluded.revoked_at,scope=excluded.scope`, grant.Agent, grant.Owner, grant.EventID, grant.Name, grant.ExpiresAt, revokedAt, string(scope)); err != nil {317+ return fmt.Errorf("record agent grant: %w", err)318+ }319+ if revokedAt > 0 {320+ if err := s.dropAgentRoleTx(ctx, tx, grant.Agent); err != nil {321+ return err322+ }323+ return s.recordTx(ctx, tx, grant.Owner, "revokeagent", grant.Agent, "expired grant")324+ }325+ // A key that already holds a human role keeps it; the grant only adds the326+ // agent role to keys that have no other standing here.327+ if _, err := tx.ExecContext(ctx, `INSERT INTO community_members(pubkey,role,invited_by,via,created_at) VALUES(?,?,?,?,?) ON CONFLICT(pubkey) DO NOTHING`, grant.Agent, "agent", grant.Owner, "grant", now); err != nil {328+ return fmt.Errorf("add agent member: %w", err)329+ }330+ return s.recordTx(ctx, tx, grant.Owner, "grantagent", grant.Agent, grant.Name)331+}332+333+// revokeDeletedGrantsTx revokes grants a kind 5 names by event id or by334+// 30392 address. Only the grant's own signer can revoke it this way.335+func (s *Service) revokeDeletedGrantsTx(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {336+ prefix := strconv.Itoa(event.KIND_AGENT_GRANT) + ":" + e.PubKey + ":"337+ for _, tag := range e.Tags {338+ if len(tag) < 2 {339+ continue340+ }341+ var agent string342+ switch tag[0] {343+ case "a":344+ if !strings.HasPrefix(tag[1], prefix) {345+ continue346+ }347+ agent = strings.TrimPrefix(tag[1], prefix)348+ case "e":349+ if err := tx.QueryRowContext(ctx, `SELECT agent FROM agent_grants WHERE event_id=? AND owner=?`, tag[1], e.PubKey).Scan(&agent); err != nil {350+ if errors.Is(err, sql.ErrNoRows) {351+ continue352+ }353+ return err354+ }355+ default:356+ continue357+ }358+ if !validPubKey(agent) {359+ continue360+ }361+ if err := s.revokeAgentTx(ctx, tx, e.PubKey, agent, now, "deleted grant"); err != nil {362+ return err363+ }364+ }365+ return nil366+}367+368+func (s *Service) revokeAgentTx(ctx context.Context, tx *sql.Tx, actor, agent string, now int64, detail string) error {369+ result, err := tx.ExecContext(ctx, `UPDATE agent_grants SET revoked_at=? WHERE agent=? AND owner=? AND revoked_at=0`, now, agent, actor)370+ if err != nil {371+ return fmt.Errorf("revoke agent grant: %w", err)372+ }373+ if n, _ := result.RowsAffected(); n == 0 {374+ return nil375+ }376+ if err := s.dropAgentRoleTx(ctx, tx, agent); err != nil {377+ return err378+ }379+ return s.recordTx(ctx, tx, actor, "revokeagent", agent, detail)380+}381+382+func (s *Service) dropAgentRoleTx(ctx context.Context, tx *sql.Tx, agent string) error {383+ if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=? AND role='agent'`, agent); err != nil {384+ return fmt.Errorf("remove agent role: %w", err)385+ }386+ return nil387+}388+389+// Agents lists every grant with the time of the agent's newest stored event.390+func (s *Service) Agents(ctx context.Context) ([]AgentSummary, error) {391+ rows, err := s.store.DB().QueryContext(ctx, `SELECT g.agent,g.owner,g.event_id,g.name,g.expires_at,g.paused,g.revoked_at,g.scope,(SELECT coalesce(max(created_at),0) FROM events WHERE pubkey=g.agent) FROM agent_grants g ORDER BY g.name,g.agent`)392+ if err != nil {393+ return nil, fmt.Errorf("list agents: %w", err)394+ }395+ defer rows.Close()396+ out := []AgentSummary{}397+ for rows.Next() {398+ var item AgentSummary399+ var paused int400+ var scope string401+ if err := rows.Scan(&item.Agent, &item.Owner, &item.EventID, &item.Name, &item.ExpiresAt, &paused, &item.RevokedAt, &scope, &item.LastEvent); err != nil {402+ return nil, err403+ }404+ item.Paused = paused != 0405+ if err := json.Unmarshal([]byte(scope), &item.Scope); err != nil {406+ return nil, fmt.Errorf("agent grant scope: %w", err)407+ }408+ out = append(out, item)409+ }410+ return out, rows.Err()411+}412+413+// ActiveAgentCount counts grants that currently allow publishing.414+func (s *Service) ActiveAgentCount(ctx context.Context, now int64) (int, error) {415+ var count int416+ err := s.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM agent_grants WHERE paused=0 AND revoked_at=0 AND expires_at>?`, now).Scan(&count)417+ if err != nil && strings.Contains(err.Error(), "no such table") {418+ return 0, nil419+ }420+ return count, err421+}422+423+// SetAgentPaused pauses or resumes one agent without touching its grant event.424+func (s *Service) SetAgentPaused(ctx context.Context, actor, agent string, paused bool) (AgentGrant, error) {425+ if !validPubKey(agent) {426+ return AgentGrant{}, errors.New("invalid: bad pubkey")427+ }428+ grant, ok, err := s.AgentGrant(ctx, agent)429+ if err != nil {430+ return AgentGrant{}, err431+ }432+ if !ok {433+ return AgentGrant{}, errors.New("invalid: no agent grant for this pubkey")434+ }435+ if grant.RevokedAt > 0 {436+ return AgentGrant{}, errors.New("invalid: agent grant is revoked")437+ }438+ action, value := "resumeagent", 0439+ if paused {440+ action, value = "pauseagent", 1441+ }442+ err = s.store.WithTx(ctx, func(tx *sql.Tx) error {443+ if _, err := tx.ExecContext(ctx, `UPDATE agent_grants SET paused=? WHERE agent=?`, value, agent); err != nil {444+ return err445+ }446+ return s.recordTx(ctx, tx, actor, action, agent, "")447+ })448+ if err != nil {449+ return AgentGrant{}, err450+ }451+ grant.Paused = paused452+ return grant, nil453+}454+455+// RevokeAgent marks a grant revoked and removes the agent role. The grant456+// event stays stored for audit.457+func (s *Service) RevokeAgent(ctx context.Context, actor, agent string, now int64) (AgentGrant, error) {458+ if !validPubKey(agent) {459+ return AgentGrant{}, errors.New("invalid: bad pubkey")460+ }461+ grant, ok, err := s.AgentGrant(ctx, agent)462+ if err != nil {463+ return AgentGrant{}, err464+ }465+ if !ok {466+ return AgentGrant{}, errors.New("invalid: no agent grant for this pubkey")467+ }468+ if grant.RevokedAt > 0 {469+ return grant, nil470+ }471+ err = s.store.WithTx(ctx, func(tx *sql.Tx) error {472+ if _, err := tx.ExecContext(ctx, `UPDATE agent_grants SET revoked_at=? WHERE agent=?`, now, agent); err != nil {473+ return err474+ }475+ if err := s.dropAgentRoleTx(ctx, tx, agent); err != nil {476+ return err477+ }478+ return s.recordTx(ctx, tx, actor, "revokeagent", agent, "management")479+ })480+ if err != nil {481+ return AgentGrant{}, err482+ }483+ grant.RevokedAt = now484+ return grant, nil485+}486+487+// SetAllAgentsPaused is the kill switch. It returns how many grants changed.488+func (s *Service) SetAllAgentsPaused(ctx context.Context, actor string, paused bool) (int, error) {489+ action, value := "resumeallagents", 0490+ if paused {491+ action, value = "pauseallagents", 1492+ }493+ changed := 0494+ err := s.store.WithTx(ctx, func(tx *sql.Tx) error {495+ result, err := tx.ExecContext(ctx, `UPDATE agent_grants SET paused=? WHERE revoked_at=0 AND paused<>?`, value, value)496+ if err != nil {497+ return err498+ }499+ n, _ := result.RowsAffected()500+ changed = int(n)501+ return s.recordTx(ctx, tx, actor, action, "", strconv.Itoa(changed))502+ })503+ return changed, err504+}
M internal/community/community.go +1 -1
M internal/event/kinds.go +1 -0
M internal/records/records.go +12 -4
@@ -286,7 +286,11 @@ func (s *Service) groupRecords(ctx context.Context, now int64) ([]event.Event, e
286286 if err := rows.Scan(&pk, &role); err != nil {287287 return nil, err288288 }289- members = append(members, []string{"p", pk})289+ if role == "agent" {290+ members = append(members, []string{"p", pk, role})291+ } else {292+ members = append(members, []string{"p", pk})293+ }290294 if role == "owner" || role == "moderator" {291295 admins = append(admins, []string{"p", pk, role})292296 }
@@ -297,8 +301,7 @@ func (s *Service) groupRecords(ctx context.Context, now int64) ([]event.Event, e
297301 if !p.DirectoryPublic {298302 members = nil299303 }300- rolesAbout := map[string]string{"owner": "relay owner", "moderator": "relay moderator", "member": "relay member"}301- for _, role := range []string{"owner", "moderator", "member"} {304+ for _, role := range roleOrder {302305 roleTags = append(roleTags, []string{"role", role, rolesAbout[role]})303306 }304307 vals := []struct {
@@ -319,7 +322,7 @@ func (s *Service) groupRecords(ctx context.Context, now int64) ([]event.Event, e
319322 }320323 out = append(out, e)321324 }322- for i, role := range []string{"owner", "moderator", "member"} {325+ for i, role := range roleOrder {323326 e, err := s.signed(ctx, event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", role}, {"label", role}, {"description", rolesAbout[role]}, {"order", strconv.Itoa(i + 1)}}, "", now)324327 if err != nil {325328 return nil, err
@@ -329,6 +332,10 @@ func (s *Service) groupRecords(ctx context.Context, now int64) ([]event.Event, e
329332 return out, nil330333 }331334 335+// roleOrder lists the roles the group advertises, in display order.336+var roleOrder = []string{"owner", "moderator", "member", "agent"}337+var rolesAbout = map[string]string{"owner": "relay owner", "moderator": "relay moderator", "member": "relay member", "agent": "agent under an owner-signed grant"}338+332339 func (s *Service) groupRecordsEmpty(ctx context.Context, now int64, p policy.Policy) ([]event.Event, error) {333340 _ = p334341 vals := []struct {
@@ -343,6 +350,7 @@ func (s *Service) groupRecordsEmpty(ctx context.Context, now int64, p policy.Pol
343350 {event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", "owner"}, {"label", "owner"}, {"description", "relay owner"}, {"order", "1"}}, ""},344351 {event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", "moderator"}, {"label", "moderator"}, {"description", "relay moderator"}, {"order", "2"}}, ""},345352 {event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", "member"}, {"label", "member"}, {"description", "relay member"}, {"order", "3"}}, ""},353+ {event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", "agent"}, {"label", "agent"}, {"description", "agent under an owner-signed grant"}, {"order", "4"}}, ""},346354 }347355 out := make([]event.Event, 0, len(vals))348356 for _, v := range vals {