tinyrelaycommit

Commit 568610dff70a

browse files at this commit | history

1diff --git a/internal/community/agents.go b/internal/community/agents.go
2new file mode 100644
3index 0000000..32560e2
4--- /dev/null
5+++ b/internal/community/agents.go
6@@ -0,0 +1,504 @@
7+package community
8+
9+// Agent identities. An agent is a member with role "agent" that publishes
10+// under a grant signed by the owner or a moderator. The grant is a normal
11+// addressable event (kind 30392) mirrored into agent_grants so the write gate
12+// can answer "may this key publish this event" with one indexed lookup. The
13+// agent stays the author of everything it publishes; the relay only decides
14+// whether to keep it.
15+
16+import (
17+ "context"
18+ "database/sql"
19+ "encoding/json"
20+ "errors"
21+ "fmt"
22+ "strconv"
23+ "strings"
24+
25+ "github.com/FelineStateMachine/tinyrelay/internal/event"
26+)
27+
28+const (
29+ AgentRateDefault = 60
30+ AgentRateMax = 600
31+ AgentGrantMaxDays = 365
32+ agentNameMax = 64
33+ agentRoomMax = 128
34+ agentListMax = 256
35+)
36+
37+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 '{}');`
38+
39+// AgentRepo is one repository an agent may work in. Level is "read" or
40+// "maintain"; maintain includes read.
41+type AgentRepo struct {
42+ Owner string `json:"owner"`
43+ Identifier string `json:"identifier"`
44+ Level string `json:"level"`
45+}
46+
47+// AgentScope is the part of a grant the gate checks on every event.
48+type AgentScope struct {
49+ Kinds []int `json:"kinds"`
50+ Rooms []string `json:"rooms"`
51+ Repos []AgentRepo `json:"repos"`
52+ Wiki string `json:"wiki,omitempty"`
53+ Rate int `json:"rate"`
54+}
55+
56+// AgentGrant mirrors one grant event plus the owner's pause and revoke marks.
57+type AgentGrant struct {
58+ Agent string `json:"pubkey"`
59+ Owner string `json:"owner"`
60+ EventID string `json:"event_id"`
61+ Name string `json:"name"`
62+ ExpiresAt int64 `json:"expires"`
63+ Paused bool `json:"paused"`
64+ RevokedAt int64 `json:"revoked"`
65+ Scope AgentScope `json:"scope"`
66+}
67+
68+// AgentSummary is the management view of a grant.
69+type AgentSummary struct {
70+ AgentGrant
71+ LastEvent int64 `json:"lastEvent"`
72+}
73+
74+// Active reports whether the grant currently lets the agent publish.
75+func (g AgentGrant) Active(now int64) bool {
76+ return !g.Paused && g.RevokedAt == 0 && g.ExpiresAt > now
77+}
78+
79+// AllowsKind reports whether the agent may publish this kind. Profiles, relay
80+// lists and auth are always allowed so an agent can identify itself.
81+func (g AgentGrant) AllowsKind(kind int) bool {
82+ if kind == event.KIND_PROFILE || kind == 10002 || kind == event.KIND_AUTH {
83+ return true
84+ }
85+ for _, allowed := range g.Scope.Kinds {
86+ if allowed == kind {
87+ return true
88+ }
89+ }
90+ return false
91+}
92+
93+// AllowsRoom reports whether the agent may publish into a room.
94+func (g AgentGrant) AllowsRoom(room string) bool {
95+ for _, allowed := range g.Scope.Rooms {
96+ if allowed == room {
97+ return true
98+ }
99+ }
100+ return false
101+}
102+
103+// RepoLevel returns the agent's level in a repository, or "" when the
104+// repository is outside the grant.
105+func (g AgentGrant) RepoLevel(owner, identifier string) string {
106+ for _, repo := range g.Scope.Repos {
107+ if repo.Owner == owner && repo.Identifier == identifier {
108+ return repo.Level
109+ }
110+ }
111+ return ""
112+}
113+
114+// Check applies the grant to one event and returns a NIP-01 rejection when
115+// the grant does not cover it.
116+func (g AgentGrant) Check(e event.Event, now int64) error {
117+ switch {
118+ case g.RevokedAt > 0:
119+ return errors.New("restricted: agent grant is revoked")
120+ case g.Paused:
121+ return errors.New("restricted: agent grant is paused")
122+ case g.ExpiresAt <= now:
123+ return errors.New("restricted: agent grant has expired")
124+ }
125+ if !g.AllowsKind(e.Kind) {
126+ return fmt.Errorf("restricted: agent grant does not allow kind %d", e.Kind)
127+ }
128+ if room := event.Tag(e, "h"); room != "" && !g.AllowsRoom(room) {
129+ return errors.New("restricted: agent grant does not allow room " + room)
130+ }
131+ if !gitKind(e.Kind) {
132+ return nil
133+ }
134+ coordinates := repositoryCoordinates(e)
135+ if len(coordinates) == 0 {
136+ if e.Kind == 1111 {
137+ return nil
138+ }
139+ return errors.New("restricted: agent grant does not allow repository events without a repository address")
140+ }
141+ for _, coordinate := range coordinates {
142+ level := g.RepoLevel(coordinate[0], coordinate[1])
143+ if level == "" {
144+ return fmt.Errorf("restricted: agent grant does not allow repository %s:%s", coordinate[0], coordinate[1])
145+ }
146+ if gitStatusKind(e.Kind) && level != "maintain" {
147+ return fmt.Errorf("restricted: agent grant does not allow status changes in repository %s:%s", coordinate[0], coordinate[1])
148+ }
149+ }
150+ return nil
151+}
152+
153+func gitKind(kind int) bool {
154+ 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)
155+}
156+
157+func gitStatusKind(kind int) bool { return kind >= 1630 && kind <= 1633 }
158+
159+// repositoryCoordinates lists the 30617 owner and identifier pairs an event
160+// points at through its a or A tags.
161+func repositoryCoordinates(e event.Event) [][2]string {
162+ var out [][2]string
163+ for _, tag := range e.Tags {
164+ if len(tag) < 2 || (tag[0] != "a" && tag[0] != "A") {
165+ continue
166+ }
167+ parts := strings.SplitN(tag[1], ":", 3)
168+ if len(parts) != 3 || parts[0] != strconv.Itoa(event.KIND_REPO) || !validPubKey(parts[1]) || parts[2] == "" {
169+ continue
170+ }
171+ out = append(out, [2]string{parts[1], parts[2]})
172+ }
173+ return out
174+}
175+
176+// ParseAgentGrant validates a kind 30392 event and returns the grant it
177+// describes. Signer authority is checked by the caller.
178+func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
179+ if e.Kind != event.KIND_AGENT_GRANT {
180+ return AgentGrant{}, fmt.Errorf("invalid: kind %d is not an agent grant", e.Kind)
181+ }
182+ agent := event.Tag(e, "d")
183+ if !validPubKey(agent) {
184+ return AgentGrant{}, errors.New("invalid: agent grant needs the agent pubkey as its d tag")
185+ }
186+ if event.Tag(e, "p") != agent {
187+ return AgentGrant{}, errors.New("invalid: agent grant p tag must match its d tag")
188+ }
189+ if agent == e.PubKey {
190+ return AgentGrant{}, errors.New("invalid: agent grant may not name its signer")
191+ }
192+ expires := event.Expiration(e)
193+ if expires == 0 {
194+ return AgentGrant{}, errors.New("invalid: agent grant needs an expiration tag")
195+ }
196+ if expires > now+AgentGrantMaxDays*86400 {
197+ return AgentGrant{}, fmt.Errorf("invalid: agent grant expiration is more than %d days out", AgentGrantMaxDays)
198+ }
199+ if e.Content != "" && !json.Valid([]byte(e.Content)) {
200+ return AgentGrant{}, errors.New("invalid: agent grant content must be empty or JSON")
201+ }
202+ grant := AgentGrant{Agent: agent, Owner: e.PubKey, EventID: e.ID, ExpiresAt: expires, Scope: AgentScope{Kinds: []int{}, Rooms: []string{}, Repos: []AgentRepo{}, Rate: AgentRateDefault}}
203+ grant.Name = strings.TrimSpace(event.Tag(e, "name"))
204+ if len(grant.Name) > agentNameMax {
205+ grant.Name = grant.Name[:agentNameMax]
206+ }
207+ seenRoom := map[string]bool{}
208+ for _, room := range event.TagValues(e, "room") {
209+ room = strings.TrimSpace(room)
210+ if room == "" || len(room) > agentRoomMax {
211+ return AgentGrant{}, errors.New("invalid: agent grant room tag")
212+ }
213+ if !seenRoom[room] {
214+ seenRoom[room] = true
215+ grant.Scope.Rooms = append(grant.Scope.Rooms, room)
216+ }
217+ }
218+ for _, value := range event.TagValues(e, "repo") {
219+ repo, ok := parseAgentRepo(value)
220+ if !ok {
221+ return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier:read or owner:identifier:maintain")
222+ }
223+ if grant.RepoLevel(repo.Owner, repo.Identifier) == "" {
224+ grant.Scope.Repos = append(grant.Scope.Repos, repo)
225+ }
226+ }
227+ seenKind := map[int]bool{}
228+ for _, value := range event.TagValues(e, "k") {
229+ kind, err := strconv.Atoi(strings.TrimSpace(value))
230+ if err != nil || kind < 0 || kind > 65535 {
231+ return AgentGrant{}, errors.New("invalid: agent grant k tag must be an event kind")
232+ }
233+ if !seenKind[kind] {
234+ seenKind[kind] = true
235+ grant.Scope.Kinds = append(grant.Scope.Kinds, kind)
236+ }
237+ }
238+ if len(grant.Scope.Rooms) > agentListMax || len(grant.Scope.Repos) > agentListMax || len(grant.Scope.Kinds) > agentListMax {
239+ return AgentGrant{}, fmt.Errorf("invalid: agent grant lists more than %d entries", agentListMax)
240+ }
241+ switch wiki := event.Tag(e, "wiki"); wiki {
242+ case "", "propose", "edit":
243+ grant.Scope.Wiki = wiki
244+ default:
245+ return AgentGrant{}, errors.New("invalid: agent grant wiki tag must be propose or edit")
246+ }
247+ if value := event.Tag(e, "rate"); value != "" {
248+ rate, err := strconv.Atoi(strings.TrimSpace(value))
249+ if err != nil || rate < 1 || rate > AgentRateMax {
250+ return AgentGrant{}, fmt.Errorf("invalid: agent grant rate must be between 1 and %d events per minute", AgentRateMax)
251+ }
252+ grant.Scope.Rate = rate
253+ }
254+ return grant, nil
255+}
256+
257+func parseAgentRepo(value string) (AgentRepo, bool) {
258+ value = strings.TrimSpace(value)
259+ last := strings.LastIndex(value, ":")
260+ if last < 0 {
261+ return AgentRepo{}, false
262+ }
263+ level := value[last+1:]
264+ if level != "read" && level != "maintain" {
265+ return AgentRepo{}, false
266+ }
267+ parts := strings.SplitN(value[:last], ":", 2)
268+ if len(parts) != 2 || !validPubKey(parts[0]) || parts[1] == "" || len(parts[1]) > 256 {
269+ return AgentRepo{}, false
270+ }
271+ return AgentRepo{Owner: parts[0], Identifier: parts[1], Level: level}, true
272+}
273+
274+// CanGrantAgents reports whether a role may sign agent grants.
275+func CanGrantAgents(role string) bool { return role == "owner" || role == "moderator" }
276+
277+// AgentGrant returns the grant recorded for a pubkey, if any.
278+func (s *Service) AgentGrant(ctx context.Context, pubkey string) (AgentGrant, bool, error) {
279+ var grant AgentGrant
280+ var paused int
281+ var scope string
282+ 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)
283+ if errors.Is(err, sql.ErrNoRows) {
284+ return AgentGrant{}, false, nil
285+ }
286+ if err != nil {
287+ return AgentGrant{}, false, fmt.Errorf("agent grant: %w", err)
288+ }
289+ grant.Paused = paused != 0
290+ if err := json.Unmarshal([]byte(scope), &grant.Scope); err != nil {
291+ return AgentGrant{}, false, fmt.Errorf("agent grant scope: %w", err)
292+ }
293+ return grant, true, nil
294+}
295+
296+// ApplyAgentEventTx mirrors a grant or a deletion into agent_grants and the
297+// member roster inside the transaction that stores the event, so the role and
298+// the event land or roll back together.
299+func (s *Service) ApplyAgentEventTx(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {
300+ switch e.Kind {
301+ case event.KIND_AGENT_GRANT:
302+ grant, err := ParseAgentGrant(e, now)
303+ if err != nil {
304+ return err
305+ }
306+ return s.applyAgentGrantTx(ctx, tx, grant, now)
307+ case event.KIND_DELETION:
308+ return s.revokeDeletedGrantsTx(ctx, tx, e, now)
309+ }
310+ return nil
311+}
312+
313+func (s *Service) applyAgentGrantTx(ctx context.Context, tx *sql.Tx, grant AgentGrant, now int64) error {
314+ scope, err := json.Marshal(grant.Scope)
315+ if err != nil {
316+ return err
317+ }
318+ revokedAt := int64(0)
319+ if grant.ExpiresAt <= now {
320+ revokedAt = now
321+ }
322+ 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 {
323+ return fmt.Errorf("record agent grant: %w", err)
324+ }
325+ if revokedAt > 0 {
326+ if err := s.dropAgentRoleTx(ctx, tx, grant.Agent); err != nil {
327+ return err
328+ }
329+ return s.recordTx(ctx, tx, grant.Owner, "revokeagent", grant.Agent, "expired grant")
330+ }
331+ // A key that already holds a human role keeps it; the grant only adds the
332+ // agent role to keys that have no other standing here.
333+ 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 {
334+ return fmt.Errorf("add agent member: %w", err)
335+ }
336+ return s.recordTx(ctx, tx, grant.Owner, "grantagent", grant.Agent, grant.Name)
337+}
338+
339+// revokeDeletedGrantsTx revokes grants a kind 5 names by event id or by
340+// 30392 address. Only the grant's own signer can revoke it this way.
341+func (s *Service) revokeDeletedGrantsTx(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {
342+ prefix := strconv.Itoa(event.KIND_AGENT_GRANT) + ":" + e.PubKey + ":"
343+ for _, tag := range e.Tags {
344+ if len(tag) < 2 {
345+ continue
346+ }
347+ var agent string
348+ switch tag[0] {
349+ case "a":
350+ if !strings.HasPrefix(tag[1], prefix) {
351+ continue
352+ }
353+ agent = strings.TrimPrefix(tag[1], prefix)
354+ case "e":
355+ if err := tx.QueryRowContext(ctx, `SELECT agent FROM agent_grants WHERE event_id=? AND owner=?`, tag[1], e.PubKey).Scan(&agent); err != nil {
356+ if errors.Is(err, sql.ErrNoRows) {
357+ continue
358+ }
359+ return err
360+ }
361+ default:
362+ continue
363+ }
364+ if !validPubKey(agent) {
365+ continue
366+ }
367+ if err := s.revokeAgentTx(ctx, tx, e.PubKey, agent, now, "deleted grant"); err != nil {
368+ return err
369+ }
370+ }
371+ return nil
372+}
373+
374+func (s *Service) revokeAgentTx(ctx context.Context, tx *sql.Tx, actor, agent string, now int64, detail string) error {
375+ result, err := tx.ExecContext(ctx, `UPDATE agent_grants SET revoked_at=? WHERE agent=? AND owner=? AND revoked_at=0`, now, agent, actor)
376+ if err != nil {
377+ return fmt.Errorf("revoke agent grant: %w", err)
378+ }
379+ if n, _ := result.RowsAffected(); n == 0 {
380+ return nil
381+ }
382+ if err := s.dropAgentRoleTx(ctx, tx, agent); err != nil {
383+ return err
384+ }
385+ return s.recordTx(ctx, tx, actor, "revokeagent", agent, detail)
386+}
387+
388+func (s *Service) dropAgentRoleTx(ctx context.Context, tx *sql.Tx, agent string) error {
389+ if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=? AND role='agent'`, agent); err != nil {
390+ return fmt.Errorf("remove agent role: %w", err)
391+ }
392+ return nil
393+}
394+
395+// Agents lists every grant with the time of the agent's newest stored event.
396+func (s *Service) Agents(ctx context.Context) ([]AgentSummary, error) {
397+ 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`)
398+ if err != nil {
399+ return nil, fmt.Errorf("list agents: %w", err)
400+ }
401+ defer rows.Close()
402+ out := []AgentSummary{}
403+ for rows.Next() {
404+ var item AgentSummary
405+ var paused int
406+ var scope string
407+ if err := rows.Scan(&item.Agent, &item.Owner, &item.EventID, &item.Name, &item.ExpiresAt, &paused, &item.RevokedAt, &scope, &item.LastEvent); err != nil {
408+ return nil, err
409+ }
410+ item.Paused = paused != 0
411+ if err := json.Unmarshal([]byte(scope), &item.Scope); err != nil {
412+ return nil, fmt.Errorf("agent grant scope: %w", err)
413+ }
414+ out = append(out, item)
415+ }
416+ return out, rows.Err()
417+}
418+
419+// ActiveAgentCount counts grants that currently allow publishing.
420+func (s *Service) ActiveAgentCount(ctx context.Context, now int64) (int, error) {
421+ var count int
422+ err := s.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM agent_grants WHERE paused=0 AND revoked_at=0 AND expires_at>?`, now).Scan(&count)
423+ if err != nil && strings.Contains(err.Error(), "no such table") {
424+ return 0, nil
425+ }
426+ return count, err
427+}
428+
429+// SetAgentPaused pauses or resumes one agent without touching its grant event.
430+func (s *Service) SetAgentPaused(ctx context.Context, actor, agent string, paused bool) (AgentGrant, error) {
431+ if !validPubKey(agent) {
432+ return AgentGrant{}, errors.New("invalid: bad pubkey")
433+ }
434+ grant, ok, err := s.AgentGrant(ctx, agent)
435+ if err != nil {
436+ return AgentGrant{}, err
437+ }
438+ if !ok {
439+ return AgentGrant{}, errors.New("invalid: no agent grant for this pubkey")
440+ }
441+ if grant.RevokedAt > 0 {
442+ return AgentGrant{}, errors.New("invalid: agent grant is revoked")
443+ }
444+ action, value := "resumeagent", 0
445+ if paused {
446+ action, value = "pauseagent", 1
447+ }
448+ err = s.store.WithTx(ctx, func(tx *sql.Tx) error {
449+ if _, err := tx.ExecContext(ctx, `UPDATE agent_grants SET paused=? WHERE agent=?`, value, agent); err != nil {
450+ return err
451+ }
452+ return s.recordTx(ctx, tx, actor, action, agent, "")
453+ })
454+ if err != nil {
455+ return AgentGrant{}, err
456+ }
457+ grant.Paused = paused
458+ return grant, nil
459+}
460+
461+// RevokeAgent marks a grant revoked and removes the agent role. The grant
462+// event stays stored for audit.
463+func (s *Service) RevokeAgent(ctx context.Context, actor, agent string, now int64) (AgentGrant, error) {
464+ if !validPubKey(agent) {
465+ return AgentGrant{}, errors.New("invalid: bad pubkey")
466+ }
467+ grant, ok, err := s.AgentGrant(ctx, agent)
468+ if err != nil {
469+ return AgentGrant{}, err
470+ }
471+ if !ok {
472+ return AgentGrant{}, errors.New("invalid: no agent grant for this pubkey")
473+ }
474+ if grant.RevokedAt > 0 {
475+ return grant, nil
476+ }
477+ err = s.store.WithTx(ctx, func(tx *sql.Tx) error {
478+ if _, err := tx.ExecContext(ctx, `UPDATE agent_grants SET revoked_at=? WHERE agent=?`, now, agent); err != nil {
479+ return err
480+ }
481+ if err := s.dropAgentRoleTx(ctx, tx, agent); err != nil {
482+ return err
483+ }
484+ return s.recordTx(ctx, tx, actor, "revokeagent", agent, "management")
485+ })
486+ if err != nil {
487+ return AgentGrant{}, err
488+ }
489+ grant.RevokedAt = now
490+ return grant, nil
491+}
492+
493+// SetAllAgentsPaused is the kill switch. It returns how many grants changed.
494+func (s *Service) SetAllAgentsPaused(ctx context.Context, actor string, paused bool) (int, error) {
495+ action, value := "resumeallagents", 0
496+ if paused {
497+ action, value = "pauseallagents", 1
498+ }
499+ changed := 0
500+ err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
501+ result, err := tx.ExecContext(ctx, `UPDATE agent_grants SET paused=? WHERE revoked_at=0 AND paused<>?`, value, value)
502+ if err != nil {
503+ return err
504+ }
505+ n, _ := result.RowsAffected()
506+ changed = int(n)
507+ return s.recordTx(ctx, tx, actor, action, "", strconv.Itoa(changed))
508+ })
509+ return changed, err
510+}
511diff --git a/internal/community/community.go b/internal/community/community.go
512index 3cfa77d..ef51221 100644
513--- a/internal/community/community.go
514+++ b/internal/community/community.go
515@@ -101,7 +101,7 @@ func New(ctx context.Context, store *storage.Store, owner string) (*Service, err
516 return nil, errors.New("community: invalid owner pubkey")
517 }
518 s := &Service{store: store, owner: owner}
519- if _, err := store.DB().ExecContext(ctx, schema); err != nil {
520+ if _, err := store.DB().ExecContext(ctx, schema+agentSchema); err != nil {
521 return nil, fmt.Errorf("community schema: %w", err)
522 }
523 if err := store.WithTx(ctx, func(tx *sql.Tx) error {
524diff --git a/internal/event/kinds.go b/internal/event/kinds.go
525index b8decc7..77e5a8c 100644
526--- a/internal/event/kinds.go
527+++ b/internal/event/kinds.go
528@@ -47,4 +47,5 @@ const (
529 KIND_GIT_PR_UPDATE = 1619
530 KIND_GIT_ISSUE = 1621
531 KIND_PUSH_REGISTRATION = 30390
532+ KIND_AGENT_GRANT = 30392
533 )
534diff --git a/internal/records/records.go b/internal/records/records.go
535index d1350f0..444d99e 100644
536--- a/internal/records/records.go
537+++ b/internal/records/records.go
538@@ -286,7 +286,11 @@ func (s *Service) groupRecords(ctx context.Context, now int64) ([]event.Event, e
539 if err := rows.Scan(&pk, &role); err != nil {
540 return nil, err
541 }
542- members = append(members, []string{"p", pk})
543+ if role == "agent" {
544+ members = append(members, []string{"p", pk, role})
545+ } else {
546+ members = append(members, []string{"p", pk})
547+ }
548 if role == "owner" || role == "moderator" {
549 admins = append(admins, []string{"p", pk, role})
550 }
551@@ -297,8 +301,7 @@ func (s *Service) groupRecords(ctx context.Context, now int64) ([]event.Event, e
552 if !p.DirectoryPublic {
553 members = nil
554 }
555- rolesAbout := map[string]string{"owner": "relay owner", "moderator": "relay moderator", "member": "relay member"}
556- for _, role := range []string{"owner", "moderator", "member"} {
557+ for _, role := range roleOrder {
558 roleTags = append(roleTags, []string{"role", role, rolesAbout[role]})
559 }
560 vals := []struct {
561@@ -319,7 +322,7 @@ func (s *Service) groupRecords(ctx context.Context, now int64) ([]event.Event, e
562 }
563 out = append(out, e)
564 }
565- for i, role := range []string{"owner", "moderator", "member"} {
566+ for i, role := range roleOrder {
567 e, err := s.signed(ctx, event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", role}, {"label", role}, {"description", rolesAbout[role]}, {"order", strconv.Itoa(i + 1)}}, "", now)
568 if err != nil {
569 return nil, err
570@@ -329,6 +332,10 @@ func (s *Service) groupRecords(ctx context.Context, now int64) ([]event.Event, e
571 return out, nil
572 }
573
574+// roleOrder lists the roles the group advertises, in display order.
575+var roleOrder = []string{"owner", "moderator", "member", "agent"}
576+var rolesAbout = map[string]string{"owner": "relay owner", "moderator": "relay moderator", "member": "relay member", "agent": "agent under an owner-signed grant"}
577+
578 func (s *Service) groupRecordsEmpty(ctx context.Context, now int64, p policy.Policy) ([]event.Event, error) {
579 _ = p
580 vals := []struct {
581@@ -343,6 +350,7 @@ func (s *Service) groupRecordsEmpty(ctx context.Context, now int64, p policy.Pol
582 {event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", "owner"}, {"label", "owner"}, {"description", "relay owner"}, {"order", "1"}}, ""},
583 {event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", "moderator"}, {"label", "moderator"}, {"description", "relay moderator"}, {"order", "2"}}, ""},
584 {event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", "member"}, {"label", "member"}, {"description", "relay member"}, {"order", "3"}}, ""},
585+ {event.KIND_ROLE_DEF, [][]string{{"-"}, {"d", "agent"}, {"label", "agent"}, {"description", "agent under an owner-signed grant"}, {"order", "4"}}, ""},
586 }
587 out := make([]event.Event, 0, len(vals))
588 for _, v := range vals {

About

ownerc01503e8035f
visibilitypublic
head568610dff70a

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs