1package community23// 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.910import (11 "context"12 "database/sql"13 "encoding/json"14 "errors"15 "fmt"16 "strconv"17 "strings"1819 "github.com/FelineStateMachine/tinyrelay/internal/event"20 "github.com/FelineStateMachine/tinyrelay/internal/sites"21)2223const (24 AgentRateDefault = 6025 AgentRateMax = 60026 AgentGrantMaxDays = 36527 AgentSiteTTLMax = 36528 agentNameMax = 6429 agentRoomMax = 12830 agentListMax = 25631)3233const 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 '{}');`3435// AgentRepo is one repository an agent may work in. Level is "propose",36// "read" or "maintain". Propose and read publish the same kinds, but each37// event from a propose grant is a proposal that waits for a maintainer's38// 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}4445// Repository grant levels.46const (47 AgentRepoPropose = "propose"48 AgentRepoRead = "read"49 AgentRepoMaintain = "maintain"50)5152// Long task grants. AgentJobsRequest lets the agent publish job requests,53// AgentJobsServe lets it answer requests the relay holds with results and54// feedback, and AgentJobsBoth does both.55const (56 AgentJobsRequest = "request"57 AgentJobsServe = "serve"58 AgentJobsBoth = "both"59)6061// AgentSite is one static site an agent may publish. Label is a site label62// under the agent's own key or "*" for any of them. TTLDays, when set, bounds63// how long a manifest and the files behind it live; Encrypted requires the64// 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}7071// 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}8182// 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}9394// AgentSummary is the management view of a grant.95type AgentSummary struct {96 AgentGrant97 LastEvent int64 `json:"lastEvent"`98}99100// 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 > now103}104105// Maintains reports whether the grant currently makes the agent a maintainer106// 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) == AgentRepoMaintain109}110111// Proposes reports whether the grant holds propose on one repository, so112// the agent's issues, pull requests, patches and comments there are113// proposals. A paused, revoked or expired grant keeps counting, so an114// 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) == AgentRepoPropose117}118119// AllowsKind reports whether the agent may publish this kind. Profiles, relay120// 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 true124 }125 for _, allowed := range g.Scope.Kinds {126 if allowed == kind {127 return true128 }129 }130 if event.IsJobRequest(kind) && g.RequestsJobs() {131 return true132 }133 if (event.IsJobResult(kind) || kind == event.KIND_JOB_FEEDBACK) && g.ServesJobs() {134 return true135 }136 if siteManifestKind(kind) && len(g.Scope.Sites) > 0 {137 return true138 }139 // Wiki access carries its kinds: proposing means publishing a version and140 // 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_MERGE144 case "edit":145 return kind == event.KIND_WIKI_ARTICLE || kind == event.KIND_WIKI_MERGE || kind == event.KIND_WIKI_REDIRECT146 }147 return false148}149150// siteManifestKind reports whether a kind is a site manifest a grant's sites151// tag admits: the key's own site and named sites under it. Snapshots are152// 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.KindNamedSite155}156157// SiteEntries lists the grant's site entries that cover one label.158func (g AgentGrant) SiteEntries(label string) []AgentSite {159 var out []AgentSite160 for _, site := range g.Scope.Sites {161 if site.Label == "*" || (label != "" && site.Label == label) {162 out = append(out, site)163 }164 }165 return out166}167168// UploadTTLDays is the number of days the agent's uploads live: the longest169// 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) }171172// UploadsEncrypted reports whether the agent's uploads must be encrypted.173// One entry with the flag applies it to every upload the agent makes, since174// 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 true179 }180 }181 return false182}183184func longestTTL(entries []AgentSite) int {185 longest := 0186 for _, site := range entries {187 if site.TTLDays == 0 {188 return 0189 }190 if site.TTLDays > longest {191 longest = site.TTLDays192 }193 }194 return longest195}196197// RequestsJobs reports whether the jobs tag lets the agent publish job198// requests.199func (g AgentGrant) RequestsJobs() bool {200 return g.Scope.Jobs == AgentJobsRequest || g.Scope.Jobs == AgentJobsBoth201}202203// ServesJobs reports whether the jobs tag lets the agent answer job204// requests with results and feedback.205func (g AgentGrant) ServesJobs() bool {206 return g.Scope.Jobs == AgentJobsServe || g.Scope.Jobs == AgentJobsBoth207}208209// 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 true214 }215 }216 return false217}218219// RepoLevel returns the agent's level in a repository, or "" when the220// 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.Level225 }226 }227 return ""228}229230// Check applies the grant to one event and returns a NIP-01 rejection when231// 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 nil252 }253 coordinates := repositoryCoordinates(e)254 if len(coordinates) == 0 {255 if e.Kind == 1111 {256 return nil257 }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 nil270}271272// checkSite applies the sites entries to a manifest: the label must be273// covered, and when the covering entries carry a ttl the manifest must274// 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 nil284 }285 latest := now + int64(ttl)*86400286 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 nil294}295296func 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}299300func gitStatusKind(kind int) bool { return kind >= 1630 && kind <= 1633 }301302// repositoryCoordinates lists the 30617 owner and identifier pairs an event303// points at through its a or A tags.304func repositoryCoordinates(e event.Event) [][2]string {305 var out [][2]string306 for _, tag := range e.Tags {307 if len(tag) < 2 || (tag[0] != "a" && tag[0] != "A") {308 continue309 }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 continue313 }314 out = append(out, [2]string{parts[1], parts[2]})315 }316 return out317}318319// ParseAgentGrant validates a kind 30392 event and returns the grant it320// 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] = true358 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] = true378 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 = wiki387 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 = jobs393 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 continue399 }400 site, err := parseAgentSite(tag[1:], agent)401 if err != nil {402 return AgentGrant{}, err403 }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 = rate417 }418 return grant, nil419}420421func parseAgentRepo(value string) (AgentRepo, bool) {422 value = strings.TrimSpace(value)423 last := strings.LastIndex(value, ":")424 if last < 0 {425 return AgentRepo{}, false426 }427 level := value[last+1:]428 if level != AgentRepoPropose && level != AgentRepoRead && level != AgentRepoMaintain {429 return AgentRepo{}, false430 }431 parts := strings.SplitN(value[:last], ":", 2)432 if len(parts) != 2 || !validPubKey(parts[0]) || parts[1] == "" || len(parts[1]) > 256 {433 return AgentRepo{}, false434 }435 return AgentRepo{Owner: parts[0], Identifier: parts[1], Level: level}, true436}437438// parseAgentSite reads one sites tag: a label, then any of ttl=<days> and439// 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 = true459 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 = days465 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, nil470}471472func siteListed(entries []AgentSite, label string) bool {473 for _, site := range entries {474 if site.Label == label {475 return true476 }477 }478 return false479}480481// CanGrantAgents reports whether a role may sign agent grants.482func CanGrantAgents(role string) bool { return role == "owner" || role == "moderator" }483484// 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 AgentGrant487 var paused int488 var scope string489 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, nil492 }493 if err != nil {494 return AgentGrant{}, false, fmt.Errorf("agent grant: %w", err)495 }496 grant.Paused = paused != 0497 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, nil501}502503// ApplyAgentEventTx mirrors a grant or a deletion into agent_grants and the504// member roster inside the transaction that stores the event, so the role and505// 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 err512 }513 }514 grant, err := ParseAgentGrant(e, now)515 if err != nil {516 return err517 }518 return s.applyAgentGrantTx(ctx, tx, grant, now)519 case event.KIND_DELETION:520 return s.revokeDeletedGrantsTx(ctx, tx, e, now)521 }522 return nil523}524525func (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 err529 }530 revokedAt := int64(0)531 if grant.ExpiresAt <= now {532 revokedAt = now533 }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 err540 }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 the544 // 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}550551// revokeDeletedGrantsTx revokes grants a kind 5 names by event id or by552// 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 continue558 }559 var agent string560 switch tag[0] {561 case "a":562 if !strings.HasPrefix(tag[1], prefix) {563 continue564 }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 continue570 }571 return err572 }573 default:574 continue575 }576 if !validPubKey(agent) {577 continue578 }579 if err := s.revokeAgentTx(ctx, tx, e.PubKey, agent, now, "deleted grant"); err != nil {580 return err581 }582 }583 return nil584}585586func (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 nil593 }594 if err := s.dropAgentRoleTx(ctx, tx, agent); err != nil {595 return err596 }597 return s.recordTx(ctx, tx, actor, "revokeagent", agent, detail)598}599600func (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 nil605}606607// 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 AgentSummary617 var paused int618 var scope string619 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, err621 }622 item.Paused = paused != 0623 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}630631// RepositoryAgents lists the active grants that hold maintain on one632// 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, nil638 }639 return nil, fmt.Errorf("list repository agents: %w", err)640 }641 defer rows.Close()642 var out []AgentGrant643 for rows.Next() {644 var grant AgentGrant645 var scope string646 if err := rows.Scan(&grant.Agent, &grant.Owner, &grant.EventID, &grant.Name, &grant.ExpiresAt, &scope); err != nil {647 return nil, err648 }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}658659// ActiveAgentCount counts grants that currently allow publishing.660func (s *Service) ActiveAgentCount(ctx context.Context, now int64) (int, error) {661 var count int662 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, nil665 }666 return count, err667}668669// 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{}, err677 }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", 0685 if paused {686 action, value = "pauseagent", 1687 }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 err691 }692 return s.recordTx(ctx, tx, actor, action, agent, "")693 })694 if err != nil {695 return AgentGrant{}, err696 }697 grant.Paused = paused698 return grant, nil699}700701// RevokeAgent marks a grant revoked and removes the agent role. The grant702// 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{}, err710 }711 if !ok {712 return AgentGrant{}, errors.New("invalid: no agent grant for this pubkey")713 }714 if grant.RevokedAt > 0 {715 return grant, nil716 }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 err720 }721 if err := s.dropAgentRoleTx(ctx, tx, agent); err != nil {722 return err723 }724 return s.recordTx(ctx, tx, actor, "revokeagent", agent, "management")725 })726 if err != nil {727 return AgentGrant{}, err728 }729 grant.RevokedAt = now730 return grant, nil731}732733// 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", 0736 if paused {737 action, value = "pauseallagents", 1738 }739 changed := 0740 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 err744 }745 n, _ := result.RowsAffected()746 changed = int(n)747 return s.recordTx(ctx, tx, actor, action, "", strconv.Itoa(changed))748 })749 return changed, err750}