| 1 | diff --git a/internal/community/agents.go b/internal/community/agents.go |
| 2 | new file mode 100644 |
| 3 | index 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 | +} |
| 511 | diff --git a/internal/community/community.go b/internal/community/community.go |
| 512 | index 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 { |
| 524 | diff --git a/internal/event/kinds.go b/internal/event/kinds.go |
| 525 | index 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 | ) |
| 534 | diff --git a/internal/records/records.go b/internal/records/records.go |
| 535 | index 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 { |