tinyrelayfile
tinyrelay/internal/community/agents.go/

internal/community/agents.go raw
1package community
2
3// Agent identities. An agent is a member with role "agent" that publishes
4// under a grant signed by the owner or a moderator. The grant is a normal
5// addressable event (kind 30392) mirrored into agent_grants so the write gate
6// can answer "may this key publish this event" with one indexed lookup. The
7// agent stays the author of everything it publishes; the relay only decides
8// whether to keep it.
9
10import (
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 "github.com/FelineStateMachine/tinyrelay/internal/sites"
21)
22
23const (
24 AgentRateDefault = 60
25 AgentRateMax = 600
26 AgentGrantMaxDays = 365
27 AgentSiteTTLMax = 365
28 agentNameMax = 64
29 agentRoomMax = 128
30 agentListMax = 256
31)
32
33const 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 '{}');`
34
35// AgentRepo is one repository an agent may work in. Level is "propose",
36// "read" or "maintain". Propose and read publish the same kinds, but each
37// event from a propose grant is a proposal that waits for a maintainer's
38// decision; maintain adds status changes and pushes.
39type AgentRepo struct {
40 Owner string `json:"owner"`
41 Identifier string `json:"identifier"`
42 Level string `json:"level"`
43}
44
45// Repository grant levels.
46const (
47 AgentRepoPropose = "propose"
48 AgentRepoRead = "read"
49 AgentRepoMaintain = "maintain"
50)
51
52// Long task grants. AgentJobsRequest lets the agent publish job requests,
53// AgentJobsServe lets it answer requests the relay holds with results and
54// feedback, and AgentJobsBoth does both.
55const (
56 AgentJobsRequest = "request"
57 AgentJobsServe = "serve"
58 AgentJobsBoth = "both"
59)
60
61// AgentSite is one static site an agent may publish. Label is a site label
62// under the agent's own key or "*" for any of them. TTLDays, when set, bounds
63// how long a manifest and the files behind it live; Encrypted requires the
64// agent's uploads to be encrypted.
65type AgentSite struct {
66 Label string `json:"label"`
67 TTLDays int `json:"ttl,omitempty"`
68 Encrypted bool `json:"encrypted,omitempty"`
69}
70
71// AgentScope is the part of a grant the gate checks on every event.
72type AgentScope struct {
73 Kinds []int `json:"kinds"`
74 Rooms []string `json:"rooms"`
75 Repos []AgentRepo `json:"repos"`
76 Wiki string `json:"wiki,omitempty"`
77 Jobs string `json:"jobs,omitempty"`
78 Sites []AgentSite `json:"sites,omitempty"`
79 Rate int `json:"rate"`
80}
81
82// AgentGrant mirrors one grant event plus the owner's pause and revoke marks.
83type AgentGrant struct {
84 Agent string `json:"pubkey"`
85 Owner string `json:"owner"`
86 EventID string `json:"event_id"`
87 Name string `json:"name"`
88 ExpiresAt int64 `json:"expires"`
89 Paused bool `json:"paused"`
90 RevokedAt int64 `json:"revoked"`
91 Scope AgentScope `json:"scope"`
92}
93
94// AgentSummary is the management view of a grant.
95type AgentSummary struct {
96 AgentGrant
97 LastEvent int64 `json:"lastEvent"`
98}
99
100// Active reports whether the grant currently lets the agent publish.
101func (g AgentGrant) Active(now int64) bool {
102 return !g.Paused && g.RevokedAt == 0 && g.ExpiresAt > now
103}
104
105// Maintains reports whether the grant currently makes the agent a maintainer
106// of one repository: it is active and holds maintain on that exact repository.
107func (g AgentGrant) Maintains(owner, identifier string, now int64) bool {
108 return g.Active(now) && g.RepoLevel(owner, identifier) == AgentRepoMaintain
109}
110
111// Proposes reports whether the grant holds propose on one repository, so
112// the agent's issues, pull requests, patches and comments there are
113// proposals. A paused, revoked or expired grant keeps counting, so an
114// agent's pending events do not surface when its grant ends.
115func (g AgentGrant) Proposes(owner, identifier string) bool {
116 return g.RepoLevel(owner, identifier) == AgentRepoPropose
117}
118
119// AllowsKind reports whether the agent may publish this kind. Profiles, relay
120// lists and auth are always allowed so an agent can identify itself.
121func (g AgentGrant) AllowsKind(kind int) bool {
122 if kind == event.KIND_PROFILE || kind == 10002 || kind == event.KIND_AUTH {
123 return true
124 }
125 for _, allowed := range g.Scope.Kinds {
126 if allowed == kind {
127 return true
128 }
129 }
130 if event.IsJobRequest(kind) && g.RequestsJobs() {
131 return true
132 }
133 if (event.IsJobResult(kind) || kind == event.KIND_JOB_FEEDBACK) && g.ServesJobs() {
134 return true
135 }
136 if siteManifestKind(kind) && len(g.Scope.Sites) > 0 {
137 return true
138 }
139 // Wiki access carries its kinds: proposing means publishing a version and
140 // asking for a merge; editing adds redirects.
141 switch g.Scope.Wiki {
142 case "propose":
143 return kind == event.KIND_WIKI_ARTICLE || kind == event.KIND_WIKI_MERGE
144 case "edit":
145 return kind == event.KIND_WIKI_ARTICLE || kind == event.KIND_WIKI_MERGE || kind == event.KIND_WIKI_REDIRECT
146 }
147 return false
148}
149
150// siteManifestKind reports whether a kind is a site manifest a grant's sites
151// tag admits: the key's own site and named sites under it. Snapshots are
152// left out; they are copies of a site rather than a site the agent publishes.
153func siteManifestKind(kind int) bool {
154 return kind == sites.KindSite || kind == sites.KindNamedSite
155}
156
157// SiteEntries lists the grant's site entries that cover one label.
158func (g AgentGrant) SiteEntries(label string) []AgentSite {
159 var out []AgentSite
160 for _, site := range g.Scope.Sites {
161 if site.Label == "*" || (label != "" && site.Label == label) {
162 out = append(out, site)
163 }
164 }
165 return out
166}
167
168// UploadTTLDays is the number of days the agent's uploads live: the longest
169// ttl among its site entries, or 0 when there is none or an entry sets none.
170func (g AgentGrant) UploadTTLDays() int { return longestTTL(g.Scope.Sites) }
171
172// UploadsEncrypted reports whether the agent's uploads must be encrypted.
173// One entry with the flag applies it to every upload the agent makes, since
174// an upload does not say which site it is for.
175func (g AgentGrant) UploadsEncrypted() bool {
176 for _, site := range g.Scope.Sites {
177 if site.Encrypted {
178 return true
179 }
180 }
181 return false
182}
183
184func longestTTL(entries []AgentSite) int {
185 longest := 0
186 for _, site := range entries {
187 if site.TTLDays == 0 {
188 return 0
189 }
190 if site.TTLDays > longest {
191 longest = site.TTLDays
192 }
193 }
194 return longest
195}
196
197// RequestsJobs reports whether the jobs tag lets the agent publish job
198// requests.
199func (g AgentGrant) RequestsJobs() bool {
200 return g.Scope.Jobs == AgentJobsRequest || g.Scope.Jobs == AgentJobsBoth
201}
202
203// ServesJobs reports whether the jobs tag lets the agent answer job
204// requests with results and feedback.
205func (g AgentGrant) ServesJobs() bool {
206 return g.Scope.Jobs == AgentJobsServe || g.Scope.Jobs == AgentJobsBoth
207}
208
209// AllowsRoom reports whether the agent may publish into a room.
210func (g AgentGrant) AllowsRoom(room string) bool {
211 for _, allowed := range g.Scope.Rooms {
212 if allowed == room {
213 return true
214 }
215 }
216 return false
217}
218
219// RepoLevel returns the agent's level in a repository, or "" when the
220// repository is outside the grant.
221func (g AgentGrant) RepoLevel(owner, identifier string) string {
222 for _, repo := range g.Scope.Repos {
223 if repo.Owner == owner && repo.Identifier == identifier {
224 return repo.Level
225 }
226 }
227 return ""
228}
229
230// Check applies the grant to one event and returns a NIP-01 rejection when
231// the grant does not cover it.
232func (g AgentGrant) Check(e event.Event, now int64) error {
233 switch {
234 case g.RevokedAt > 0:
235 return errors.New("restricted: agent grant is revoked")
236 case g.Paused:
237 return errors.New("restricted: agent grant is paused")
238 case g.ExpiresAt <= now:
239 return errors.New("restricted: agent grant has expired")
240 }
241 if !g.AllowsKind(e.Kind) {
242 return fmt.Errorf("restricted: agent grant does not allow kind %d", e.Kind)
243 }
244 if room := event.Tag(e, "h"); room != "" && !g.AllowsRoom(room) {
245 return errors.New("restricted: agent grant does not allow room " + room)
246 }
247 if siteManifestKind(e.Kind) {
248 return g.checkSite(e, now)
249 }
250 if !gitKind(e.Kind) {
251 return nil
252 }
253 coordinates := repositoryCoordinates(e)
254 if len(coordinates) == 0 {
255 if e.Kind == 1111 {
256 return nil
257 }
258 return errors.New("restricted: agent grant does not allow repository events without a repository address")
259 }
260 for _, coordinate := range coordinates {
261 level := g.RepoLevel(coordinate[0], coordinate[1])
262 if level == "" {
263 return fmt.Errorf("restricted: agent grant does not allow repository %s:%s", coordinate[0], coordinate[1])
264 }
265 if gitStatusKind(e.Kind) && level != AgentRepoMaintain {
266 return fmt.Errorf("restricted: agent grant does not allow status changes in repository %s:%s", coordinate[0], coordinate[1])
267 }
268 }
269 return nil
270}
271
272// checkSite applies the sites entries to a manifest: the label must be
273// covered, and when the covering entries carry a ttl the manifest must
274// expire within it so NIP-40 retires the site on time.
275func (g AgentGrant) checkSite(e event.Event, now int64) error {
276 label := sites.SiteLabel(e)
277 entries := g.SiteEntries(label)
278 if len(entries) == 0 {
279 return fmt.Errorf("restricted: agent grant does not allow site %s", label)
280 }
281 ttl := longestTTL(entries)
282 if ttl == 0 {
283 return nil
284 }
285 latest := now + int64(ttl)*86400
286 expires := event.Expiration(e)
287 if expires == 0 {
288 return fmt.Errorf("invalid: agent grant allows site %s for %d days: add an expiration tag no later than %d", label, ttl, latest)
289 }
290 if expires > latest {
291 return fmt.Errorf("invalid: agent grant allows site %s for %d days: set the expiration tag no later than %d", label, ttl, latest)
292 }
293 return nil
294}
295
296func gitKind(kind int) bool {
297 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)
298}
299
300func gitStatusKind(kind int) bool { return kind >= 1630 && kind <= 1633 }
301
302// repositoryCoordinates lists the 30617 owner and identifier pairs an event
303// points at through its a or A tags.
304func repositoryCoordinates(e event.Event) [][2]string {
305 var out [][2]string
306 for _, tag := range e.Tags {
307 if len(tag) < 2 || (tag[0] != "a" && tag[0] != "A") {
308 continue
309 }
310 parts := strings.SplitN(tag[1], ":", 3)
311 if len(parts) != 3 || parts[0] != strconv.Itoa(event.KIND_REPO) || !validPubKey(parts[1]) || parts[2] == "" {
312 continue
313 }
314 out = append(out, [2]string{parts[1], parts[2]})
315 }
316 return out
317}
318
319// ParseAgentGrant validates a kind 30392 event and returns the grant it
320// describes. Signer authority is checked by the caller.
321func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
322 if e.Kind != event.KIND_AGENT_GRANT {
323 return AgentGrant{}, fmt.Errorf("invalid: kind %d is not an agent grant", e.Kind)
324 }
325 agent := event.Tag(e, "d")
326 if !validPubKey(agent) {
327 return AgentGrant{}, errors.New("invalid: agent grant needs the agent pubkey as its d tag")
328 }
329 if event.Tag(e, "p") != agent {
330 return AgentGrant{}, errors.New("invalid: agent grant p tag must match its d tag")
331 }
332 if agent == e.PubKey {
333 return AgentGrant{}, errors.New("invalid: agent grant may not name its signer")
334 }
335 expires := event.Expiration(e)
336 if expires == 0 {
337 return AgentGrant{}, errors.New("invalid: agent grant needs an expiration tag")
338 }
339 if expires > now+AgentGrantMaxDays*86400 {
340 return AgentGrant{}, fmt.Errorf("invalid: agent grant expiration is more than %d days out", AgentGrantMaxDays)
341 }
342 if e.Content != "" && !json.Valid([]byte(e.Content)) {
343 return AgentGrant{}, errors.New("invalid: agent grant content must be empty or JSON")
344 }
345 grant := AgentGrant{Agent: agent, Owner: e.PubKey, EventID: e.ID, ExpiresAt: expires, Scope: AgentScope{Kinds: []int{}, Rooms: []string{}, Repos: []AgentRepo{}, Rate: AgentRateDefault}}
346 grant.Name = strings.TrimSpace(event.Tag(e, "name"))
347 if len(grant.Name) > agentNameMax {
348 grant.Name = grant.Name[:agentNameMax]
349 }
350 seenRoom := map[string]bool{}
351 for _, room := range event.TagValues(e, "room") {
352 room = strings.TrimSpace(room)
353 if room == "" || len(room) > agentRoomMax {
354 return AgentGrant{}, errors.New("invalid: agent grant room tag")
355 }
356 if !seenRoom[room] {
357 seenRoom[room] = true
358 grant.Scope.Rooms = append(grant.Scope.Rooms, room)
359 }
360 }
361 for _, value := range event.TagValues(e, "repo") {
362 repo, ok := parseAgentRepo(value)
363 if !ok {
364 return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier followed by :propose, :read or :maintain")
365 }
366 if grant.RepoLevel(repo.Owner, repo.Identifier) == "" {
367 grant.Scope.Repos = append(grant.Scope.Repos, repo)
368 }
369 }
370 seenKind := map[int]bool{}
371 for _, value := range event.TagValues(e, "k") {
372 kind, err := strconv.Atoi(strings.TrimSpace(value))
373 if err != nil || kind < 0 || kind > 65535 {
374 return AgentGrant{}, errors.New("invalid: agent grant k tag must be an event kind")
375 }
376 if !seenKind[kind] {
377 seenKind[kind] = true
378 grant.Scope.Kinds = append(grant.Scope.Kinds, kind)
379 }
380 }
381 if len(grant.Scope.Rooms) > agentListMax || len(grant.Scope.Repos) > agentListMax || len(grant.Scope.Kinds) > agentListMax {
382 return AgentGrant{}, fmt.Errorf("invalid: agent grant lists more than %d entries", agentListMax)
383 }
384 switch wiki := event.Tag(e, "wiki"); wiki {
385 case "", "propose", "edit":
386 grant.Scope.Wiki = wiki
387 default:
388 return AgentGrant{}, errors.New("invalid: agent grant wiki tag must be propose or edit")
389 }
390 switch jobs := event.Tag(e, "jobs"); jobs {
391 case "", AgentJobsRequest, AgentJobsServe, AgentJobsBoth:
392 grant.Scope.Jobs = jobs
393 default:
394 return AgentGrant{}, errors.New("invalid: agent grant jobs tag must be request, serve or both")
395 }
396 for _, tag := range e.Tags {
397 if len(tag) == 0 || tag[0] != "sites" {
398 continue
399 }
400 site, err := parseAgentSite(tag[1:], agent)
401 if err != nil {
402 return AgentGrant{}, err
403 }
404 if !siteListed(grant.Scope.Sites, site.Label) {
405 grant.Scope.Sites = append(grant.Scope.Sites, site)
406 }
407 }
408 if len(grant.Scope.Sites) > agentListMax {
409 return AgentGrant{}, fmt.Errorf("invalid: agent grant lists more than %d entries", agentListMax)
410 }
411 if value := event.Tag(e, "rate"); value != "" {
412 rate, err := strconv.Atoi(strings.TrimSpace(value))
413 if err != nil || rate < 1 || rate > AgentRateMax {
414 return AgentGrant{}, fmt.Errorf("invalid: agent grant rate must be between 1 and %d events per minute", AgentRateMax)
415 }
416 grant.Scope.Rate = rate
417 }
418 return grant, nil
419}
420
421func parseAgentRepo(value string) (AgentRepo, bool) {
422 value = strings.TrimSpace(value)
423 last := strings.LastIndex(value, ":")
424 if last < 0 {
425 return AgentRepo{}, false
426 }
427 level := value[last+1:]
428 if level != AgentRepoPropose && level != AgentRepoRead && level != AgentRepoMaintain {
429 return AgentRepo{}, false
430 }
431 parts := strings.SplitN(value[:last], ":", 2)
432 if len(parts) != 2 || !validPubKey(parts[0]) || parts[1] == "" || len(parts[1]) > 256 {
433 return AgentRepo{}, false
434 }
435 return AgentRepo{Owner: parts[0], Identifier: parts[1], Level: level}, true
436}
437
438// parseAgentSite reads one sites tag: a label, then any of ttl=<days> and
439// encrypted. The label is "*" or a site label under the agent's own key.
440func parseAgentSite(values []string, agent string) (AgentSite, error) {
441 if len(values) == 0 {
442 return AgentSite{}, errors.New("invalid: agent grant sites tag needs a site label or *")
443 }
444 site := AgentSite{Label: strings.TrimSpace(values[0])}
445 if site.Label != "*" {
446 parsed, ok := sites.ParseSite(site.Label)
447 if !ok || parsed.Kind == sites.KindSiteSnapshot {
448 return AgentSite{}, errors.New("invalid: agent grant sites tag needs a site label or *")
449 }
450 if parsed.PubKey != agent {
451 return AgentSite{}, errors.New("invalid: agent grant sites label must be a site under the agent's own key")
452 }
453 }
454 for _, flag := range values[1:] {
455 flag = strings.TrimSpace(flag)
456 switch {
457 case flag == "encrypted":
458 site.Encrypted = true
459 case strings.HasPrefix(flag, "ttl="):
460 days, err := strconv.Atoi(strings.TrimPrefix(flag, "ttl="))
461 if err != nil || days < 1 || days > AgentSiteTTLMax {
462 return AgentSite{}, fmt.Errorf("invalid: agent grant sites ttl must be between 1 and %d days", AgentSiteTTLMax)
463 }
464 site.TTLDays = days
465 default:
466 return AgentSite{}, errors.New("invalid: agent grant sites tag allows only ttl=<days> and encrypted after the label")
467 }
468 }
469 return site, nil
470}
471
472func siteListed(entries []AgentSite, label string) bool {
473 for _, site := range entries {
474 if site.Label == label {
475 return true
476 }
477 }
478 return false
479}
480
481// CanGrantAgents reports whether a role may sign agent grants.
482func CanGrantAgents(role string) bool { return role == "owner" || role == "moderator" }
483
484// AgentGrant returns the grant recorded for a pubkey, if any.
485func (s *Service) AgentGrant(ctx context.Context, pubkey string) (AgentGrant, bool, error) {
486 var grant AgentGrant
487 var paused int
488 var scope string
489 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)
490 if errors.Is(err, sql.ErrNoRows) {
491 return AgentGrant{}, false, nil
492 }
493 if err != nil {
494 return AgentGrant{}, false, fmt.Errorf("agent grant: %w", err)
495 }
496 grant.Paused = paused != 0
497 if err := json.Unmarshal([]byte(scope), &grant.Scope); err != nil {
498 return AgentGrant{}, false, fmt.Errorf("agent grant scope: %w", err)
499 }
500 return grant, true, nil
501}
502
503// ApplyAgentEventTx mirrors a grant or a deletion into agent_grants and the
504// member roster inside the transaction that stores the event, so the role and
505// the event land or roll back together.
506func (s *Service) ApplyAgentEventTx(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {
507 switch e.Kind {
508 case event.KIND_AGENT_GRANT:
509 if event.Tag(e, "grant-request") != "" || event.Tag(e, "grant-base") != "" {
510 if err := s.validateGrantReplacement(ctx, tx, e, now); err != nil {
511 return err
512 }
513 }
514 grant, err := ParseAgentGrant(e, now)
515 if err != nil {
516 return err
517 }
518 return s.applyAgentGrantTx(ctx, tx, grant, now)
519 case event.KIND_DELETION:
520 return s.revokeDeletedGrantsTx(ctx, tx, e, now)
521 }
522 return nil
523}
524
525func (s *Service) applyAgentGrantTx(ctx context.Context, tx *sql.Tx, grant AgentGrant, now int64) error {
526 scope, err := json.Marshal(grant.Scope)
527 if err != nil {
528 return err
529 }
530 revokedAt := int64(0)
531 if grant.ExpiresAt <= now {
532 revokedAt = now
533 }
534 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 {
535 return fmt.Errorf("record agent grant: %w", err)
536 }
537 if revokedAt > 0 {
538 if err := s.dropAgentRoleTx(ctx, tx, grant.Agent); err != nil {
539 return err
540 }
541 return s.recordTx(ctx, tx, grant.Owner, "revokeagent", grant.Agent, "expired grant")
542 }
543 // A key that already holds a human role keeps it; the grant only adds the
544 // agent role to keys that have no other standing here.
545 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 {
546 return fmt.Errorf("add agent member: %w", err)
547 }
548 return s.recordTx(ctx, tx, grant.Owner, "grantagent", grant.Agent, grant.Name)
549}
550
551// revokeDeletedGrantsTx revokes grants a kind 5 names by event id or by
552// 30392 address. Only the grant's own signer can revoke it this way.
553func (s *Service) revokeDeletedGrantsTx(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {
554 prefix := strconv.Itoa(event.KIND_AGENT_GRANT) + ":" + e.PubKey + ":"
555 for _, tag := range e.Tags {
556 if len(tag) < 2 {
557 continue
558 }
559 var agent string
560 switch tag[0] {
561 case "a":
562 if !strings.HasPrefix(tag[1], prefix) {
563 continue
564 }
565 agent = strings.TrimPrefix(tag[1], prefix)
566 case "e":
567 if err := tx.QueryRowContext(ctx, `SELECT agent FROM agent_grants WHERE event_id=? AND owner=?`, tag[1], e.PubKey).Scan(&agent); err != nil {
568 if errors.Is(err, sql.ErrNoRows) {
569 continue
570 }
571 return err
572 }
573 default:
574 continue
575 }
576 if !validPubKey(agent) {
577 continue
578 }
579 if err := s.revokeAgentTx(ctx, tx, e.PubKey, agent, now, "deleted grant"); err != nil {
580 return err
581 }
582 }
583 return nil
584}
585
586func (s *Service) revokeAgentTx(ctx context.Context, tx *sql.Tx, actor, agent string, now int64, detail string) error {
587 result, err := tx.ExecContext(ctx, `UPDATE agent_grants SET revoked_at=? WHERE agent=? AND owner=? AND revoked_at=0`, now, agent, actor)
588 if err != nil {
589 return fmt.Errorf("revoke agent grant: %w", err)
590 }
591 if n, _ := result.RowsAffected(); n == 0 {
592 return nil
593 }
594 if err := s.dropAgentRoleTx(ctx, tx, agent); err != nil {
595 return err
596 }
597 return s.recordTx(ctx, tx, actor, "revokeagent", agent, detail)
598}
599
600func (s *Service) dropAgentRoleTx(ctx context.Context, tx *sql.Tx, agent string) error {
601 if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=? AND role='agent'`, agent); err != nil {
602 return fmt.Errorf("remove agent role: %w", err)
603 }
604 return nil
605}
606
607// Agents lists every grant with the time of the agent's newest stored event.
608func (s *Service) Agents(ctx context.Context) ([]AgentSummary, error) {
609 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`)
610 if err != nil {
611 return nil, fmt.Errorf("list agents: %w", err)
612 }
613 defer rows.Close()
614 out := []AgentSummary{}
615 for rows.Next() {
616 var item AgentSummary
617 var paused int
618 var scope string
619 if err := rows.Scan(&item.Agent, &item.Owner, &item.EventID, &item.Name, &item.ExpiresAt, &paused, &item.RevokedAt, &scope, &item.LastEvent); err != nil {
620 return nil, err
621 }
622 item.Paused = paused != 0
623 if err := json.Unmarshal([]byte(scope), &item.Scope); err != nil {
624 return nil, fmt.Errorf("agent grant scope: %w", err)
625 }
626 out = append(out, item)
627 }
628 return out, rows.Err()
629}
630
631// RepositoryAgents lists the active grants that hold maintain on one
632// repository, in name order. Paused, revoked and expired grants are left out.
633func (s *Service) RepositoryAgents(ctx context.Context, owner, identifier string, now int64) ([]AgentGrant, error) {
634 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)
635 if err != nil {
636 if strings.Contains(err.Error(), "no such table") {
637 return nil, nil
638 }
639 return nil, fmt.Errorf("list repository agents: %w", err)
640 }
641 defer rows.Close()
642 var out []AgentGrant
643 for rows.Next() {
644 var grant AgentGrant
645 var scope string
646 if err := rows.Scan(&grant.Agent, &grant.Owner, &grant.EventID, &grant.Name, &grant.ExpiresAt, &scope); err != nil {
647 return nil, err
648 }
649 if err := json.Unmarshal([]byte(scope), &grant.Scope); err != nil {
650 return nil, fmt.Errorf("agent grant scope: %w", err)
651 }
652 if grant.Maintains(owner, identifier, now) {
653 out = append(out, grant)
654 }
655 }
656 return out, rows.Err()
657}
658
659// ActiveAgentCount counts grants that currently allow publishing.
660func (s *Service) ActiveAgentCount(ctx context.Context, now int64) (int, error) {
661 var count int
662 err := s.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM agent_grants WHERE paused=0 AND revoked_at=0 AND expires_at>?`, now).Scan(&count)
663 if err != nil && strings.Contains(err.Error(), "no such table") {
664 return 0, nil
665 }
666 return count, err
667}
668
669// SetAgentPaused pauses or resumes one agent without touching its grant event.
670func (s *Service) SetAgentPaused(ctx context.Context, actor, agent string, paused bool) (AgentGrant, error) {
671 if !validPubKey(agent) {
672 return AgentGrant{}, errors.New("invalid: bad pubkey")
673 }
674 grant, ok, err := s.AgentGrant(ctx, agent)
675 if err != nil {
676 return AgentGrant{}, err
677 }
678 if !ok {
679 return AgentGrant{}, errors.New("invalid: no agent grant for this pubkey")
680 }
681 if grant.RevokedAt > 0 {
682 return AgentGrant{}, errors.New("invalid: agent grant is revoked")
683 }
684 action, value := "resumeagent", 0
685 if paused {
686 action, value = "pauseagent", 1
687 }
688 err = s.store.WithTx(ctx, func(tx *sql.Tx) error {
689 if _, err := tx.ExecContext(ctx, `UPDATE agent_grants SET paused=? WHERE agent=?`, value, agent); err != nil {
690 return err
691 }
692 return s.recordTx(ctx, tx, actor, action, agent, "")
693 })
694 if err != nil {
695 return AgentGrant{}, err
696 }
697 grant.Paused = paused
698 return grant, nil
699}
700
701// RevokeAgent marks a grant revoked and removes the agent role. The grant
702// event stays stored for audit.
703func (s *Service) RevokeAgent(ctx context.Context, actor, agent string, now int64) (AgentGrant, error) {
704 if !validPubKey(agent) {
705 return AgentGrant{}, errors.New("invalid: bad pubkey")
706 }
707 grant, ok, err := s.AgentGrant(ctx, agent)
708 if err != nil {
709 return AgentGrant{}, err
710 }
711 if !ok {
712 return AgentGrant{}, errors.New("invalid: no agent grant for this pubkey")
713 }
714 if grant.RevokedAt > 0 {
715 return grant, nil
716 }
717 err = s.store.WithTx(ctx, func(tx *sql.Tx) error {
718 if _, err := tx.ExecContext(ctx, `UPDATE agent_grants SET revoked_at=? WHERE agent=?`, now, agent); err != nil {
719 return err
720 }
721 if err := s.dropAgentRoleTx(ctx, tx, agent); err != nil {
722 return err
723 }
724 return s.recordTx(ctx, tx, actor, "revokeagent", agent, "management")
725 })
726 if err != nil {
727 return AgentGrant{}, err
728 }
729 grant.RevokedAt = now
730 return grant, nil
731}
732
733// SetAllAgentsPaused is the kill switch. It returns how many grants changed.
734func (s *Service) SetAllAgentsPaused(ctx context.Context, actor string, paused bool) (int, error) {
735 action, value := "resumeallagents", 0
736 if paused {
737 action, value = "pauseallagents", 1
738 }
739 changed := 0
740 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
741 result, err := tx.ExecContext(ctx, `UPDATE agent_grants SET paused=? WHERE revoked_at=0 AND paused<>?`, value, value)
742 if err != nil {
743 return err
744 }
745 n, _ := result.RowsAffected()
746 changed = int(n)
747 return s.recordTx(ctx, tx, actor, action, "", strconv.Itoa(changed))
748 })
749 return changed, err
750}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs