- commit
- 568610dff70a20c0f45dedb1e34677e2f5a76b37
- parent
- b9eb374694a3ad6d1e80435a0aa914bca051c8bd
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 504 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 1 | + |
| M | internal/ | 16 | +- |
@@ -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+}@@ -101,7 +101,7 @@ func New(ctx context.Context, store *storage.Store, owner string) (*Service, err
101101 return nil, errors.New("community: invalid owner pubkey")102102 }103103 s := &Service{store: store, owner: owner}104- if _, err := store.DB().ExecContext(ctx, schema); err != nil {104+ if _, err := store.DB().ExecContext(ctx, schema+agentSchema); err != nil {105105 return nil, fmt.Errorf("community schema: %w", err)106106 }107107 if err := store.WithTx(ctx, func(tx *sql.Tx) error {@@ -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 {