| 1 | package 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 | |
| 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 | "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 21 | ) |
| 22 | |
| 23 | const ( |
| 24 | AgentRateDefault = 60 |
| 25 | AgentRateMax = 600 |
| 26 | AgentGrantMaxDays = 365 |
| 27 | AgentSiteTTLMax = 365 |
| 28 | agentNameMax = 64 |
| 29 | agentRoomMax = 128 |
| 30 | agentListMax = 256 |
| 31 | ) |
| 32 | |
| 33 | 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 '{}');` |
| 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. |
| 39 | type AgentRepo struct { |
| 40 | Owner string `json:"owner"` |
| 41 | Identifier string `json:"identifier"` |
| 42 | Level string `json:"level"` |
| 43 | } |
| 44 | |
| 45 | // Repository grant levels. |
| 46 | const ( |
| 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. |
| 55 | const ( |
| 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. |
| 65 | type 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. |
| 72 | type 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. |
| 83 | type 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. |
| 95 | type AgentSummary struct { |
| 96 | AgentGrant |
| 97 | LastEvent int64 `json:"lastEvent"` |
| 98 | } |
| 99 | |
| 100 | // Active reports whether the grant currently lets the agent publish. |
| 101 | func (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. |
| 107 | func (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. |
| 115 | func (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. |
| 121 | func (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. |
| 153 | func 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. |
| 158 | func (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. |
| 170 | func (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. |
| 175 | func (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 | |
| 184 | func 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. |
| 199 | func (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. |
| 205 | func (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. |
| 210 | func (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. |
| 221 | func (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. |
| 232 | func (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. |
| 275 | func (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 | |
| 296 | func 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 | |
| 300 | func 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. |
| 304 | func 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. |
| 321 | func 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 | |
| 421 | func 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. |
| 440 | func 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 | |
| 472 | func 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. |
| 482 | func CanGrantAgents(role string) bool { return role == "owner" || role == "moderator" } |
| 483 | |
| 484 | // AgentGrant returns the grant recorded for a pubkey, if any. |
| 485 | func (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. |
| 506 | func (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 | |
| 525 | func (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. |
| 553 | func (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 | |
| 586 | func (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 | |
| 600 | func (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. |
| 608 | func (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. |
| 633 | func (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. |
| 660 | func (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. |
| 670 | func (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. |
| 703 | func (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. |
| 734 | func (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 | } |