clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / community / agent_grant_requests.go
agent_grant_requests.go 13.4K
1package community23// Agent grant requests are deliberately ordinary NIP-22 comments. The4// request describes a narrow change to the current grant; the operator's5// signed 30392 replacement remains the authority.67import (8 "context"9 "database/sql"10 "encoding/json"11 "errors"12 "fmt"13 "io"14 "reflect"15 "slices"16 "strings"17 "unicode/utf8"1819 "github.com/FelineStateMachine/tinyrelay/internal/event"20)2122const agentGrantReasonMax = 5002324type grantRequestReader interface {25 QueryRowContext(context.Context, string, ...any) *sql.Row26 QueryContext(context.Context, string, ...any) (*sql.Rows, error)27}2829func grantRequestOpen(ctx context.Context, db grantRequestReader, request event.Event, now int64) error {30 if expires := event.Expiration(request); expires != 0 && expires <= now {31 return errors.New("conflict: grant request has expired")32 }33 rows, err := db.QueryContext(ctx, `SELECT json_extract(raw,'$.content') FROM events AS reaction34 WHERE kind=7 AND pubkey=? AND (expires=0 OR expires>?) AND EXISTS(35 SELECT 1 FROM tags WHERE event_id=reaction.id AND name='e' AND value=?)`, event.Tag(request, "p"), now, request.ID)36 if err != nil {37 return err38 }39 defer rows.Close()40 for rows.Next() {41 var content string42 if err := rows.Scan(&content); err != nil {43 return err44 }45 if strings.TrimSpace(content) == "-" {46 return errors.New("conflict: grant request was denied")47 }48 }49 return rows.Err()50}5152type AgentGrantChanges struct {53 Kinds []int `json:"kinds,omitempty"`54 Rooms []string `json:"rooms,omitempty"`55 Repos []AgentRepo `json:"repos,omitempty"`56 Sites []AgentSite `json:"sites,omitempty"`57 Wiki *string `json:"wiki,omitempty"`58 Jobs *string `json:"jobs,omitempty"`59 Rate *int `json:"rate,omitempty"`60}6162type AgentGrantRequest struct {63 Base string `json:"base"`64 Changes AgentGrantChanges `json:"changes"`65}6667type AgentGrantReview struct {68 RequestID string `json:"request_id"`69 Base string `json:"base"`70 Agent string `json:"agent"`71 Operator string `json:"operator"`72 Before AgentGrant `json:"before"`73 After AgentGrant `json:"after"`74 Unsigned event.Event `json:"unsigned"`75}7677func IsAgentGrantRequest(e event.Event) bool {78 return e.Kind == 1111 && event.Tag(e, "request") == "grant"79}8081func decodeGrantRequest(e event.Event) (AgentGrantRequest, error) {82 if !IsAgentGrantRequest(e) {83 return AgentGrantRequest{}, errors.New("invalid: not an agent grant request")84 }85 if strings.TrimSpace(e.Content) == "" || len([]rune(e.Content)) > agentGrantReasonMax {86 return AgentGrantRequest{}, fmt.Errorf("invalid: grant request rationale must be 1 to %d characters", agentGrantReasonMax)87 }88 if !utf8.ValidString(e.Content) {89 return AgentGrantRequest{}, errors.New("invalid: grant request rationale is not valid UTF-8")90 }91 var req AgentGrantRequest92 dec := json.NewDecoder(strings.NewReader(event.Tag(e, "grant")))93 dec.DisallowUnknownFields()94 if err := dec.Decode(&req); err != nil || req.Base == "" {95 return AgentGrantRequest{}, errors.New("invalid: grant request needs a base and changes")96 }97 var extra any98 if err := dec.Decode(&extra); err != io.EOF {99 return AgentGrantRequest{}, errors.New("invalid: grant request JSON has trailing data")100 }101 allowed := map[string]bool{"request": true, "grant": true, "E": true, "K": true, "P": true, "e": true, "k": true, "p": true, "subject": true, "expiration": true}102 seen := map[string]bool{}103 for _, tag := range e.Tags {104 if len(tag) == 0 || !allowed[tag[0]] {105 return AgentGrantRequest{}, errors.New("invalid: grant request contains an unsupported tag")106 }107 if seen[tag[0]] {108 return AgentGrantRequest{}, errors.New("invalid: grant request contains duplicate tags")109 }110 seen[tag[0]] = true111 }112 if req.Changes.Kinds == nil && req.Changes.Rooms == nil && req.Changes.Repos == nil && req.Changes.Sites == nil && req.Changes.Wiki == nil && req.Changes.Jobs == nil && req.Changes.Rate == nil {113 return AgentGrantRequest{}, errors.New("invalid: grant request has no changes")114 }115 if err := validateGrantChanges(req.Changes); err != nil {116 return AgentGrantRequest{}, err117 }118 return req, nil119}120121func validateGrantChanges(changes AgentGrantChanges) error {122 for _, room := range changes.Rooms {123 if !ValidRoomID(room) {124 return errors.New("invalid: grant request room")125 }126 }127 repos, sites := map[string]bool{}, map[string]bool{}128 for _, repo := range changes.Repos {129 key := repo.Owner + ":" + repo.Identifier130 if repos[key] {131 return errors.New("invalid: repeated repository in grant request")132 }133 repos[key] = true134 }135 for _, site := range changes.Sites {136 if sites[site.Label] || site.TTLDays < 0 {137 return errors.New("invalid: duplicate site or negative TTL in grant request")138 }139 sites[site.Label] = true140 }141 return nil142}143144func (s *Service) ReviewAgentGrantRequest(ctx context.Context, e event.Event, now int64) (AgentGrantReview, error) {145 req, err := decodeGrantRequest(e)146 if err != nil {147 return AgentGrantReview{}, err148 }149 if err := grantRequestOpen(ctx, s.store.DB(), e, now); err != nil {150 return AgentGrantReview{}, err151 }152 operator := event.Tag(e, "p")153 if req.Base != event.Tag(e, "E") || req.Base != event.Tag(e, "e") {154 return AgentGrantReview{}, errors.New("invalid: grant request base must match its NIP-22 references")155 }156 if len(event.TagValues(e, "p")) != 1 || operator == "" {157 return AgentGrantReview{}, errors.New("invalid: grant request needs exactly one operator")158 }159 if len(event.Tag(e, "E")) != 64 || event.Tag(e, "e") != event.Tag(e, "E") || event.Tag(e, "K") != "30392" || event.Tag(e, "k") != "30392" || event.Tag(e, "P") != operator || event.Tag(e, "p") != operator {160 return AgentGrantReview{}, errors.New("invalid: grant request needs matching NIP-22 grant references")161 }162 var raw string163 var currentID string164 var paused int165 var revokedAt int64166 if err := s.store.DB().QueryRowContext(ctx, `SELECT event_id,paused,revoked_at FROM agent_grants WHERE agent=?`, e.PubKey).Scan(&currentID, &paused, &revokedAt); err != nil || currentID != req.Base {167 return AgentGrantReview{}, errors.New("conflict: grant request is no longer for the current grant")168 }169 if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, req.Base, event.KIND_AGENT_GRANT).Scan(&raw); err != nil {170 if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, req.Base).Scan(&raw); err != nil {171 return AgentGrantReview{}, errors.New("invalid: current agent grant not found")172 }173 }174 var beforeEvent event.Event175 if beforeEvent, err = event.Parse([]byte(raw)); err != nil {176 return AgentGrantReview{}, err177 }178 review, err := s.reviewAgainstGrant(e, req, beforeEvent, now)179 if err != nil {180 return AgentGrantReview{}, err181 }182 review.Before.Paused = paused != 0183 review.Before.RevokedAt = revokedAt184 if !review.Before.Active(now) {185 return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant")186 }187 return review, nil188}189190func (s *Service) reviewAgainstGrant(e event.Event, req AgentGrantRequest, beforeEvent event.Event, now int64) (AgentGrantReview, error) {191 operator := event.Tag(e, "p")192 before, err := ParseAgentGrant(beforeEvent, now)193 if err != nil {194 return AgentGrantReview{}, err195 }196 if before.Agent != e.PubKey || before.Owner != operator || !before.Active(now) {197 return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant")198 }199 afterEvent := beforeEvent200 afterEvent.Tags = grantRequestTags(beforeEvent.Tags, req.Changes)201 afterEvent.Tags = append(afterEvent.Tags, []string{"grant-request", e.ID}, []string{"grant-base", req.Base}, []string{"e", e.ID, "", "grant-request"})202 afterEvent.ID, afterEvent.Sig, afterEvent.CreatedAt = "", "", now203 if afterEvent.CreatedAt <= beforeEvent.CreatedAt {204 afterEvent.CreatedAt = beforeEvent.CreatedAt + 1205 }206 after, err := ParseAgentGrant(afterEvent, now)207 if err != nil {208 return AgentGrantReview{}, err209 }210 if reflect.DeepEqual(after.Scope, before.Scope) {211 return AgentGrantReview{}, errors.New("invalid: grant request changes nothing")212 }213 return AgentGrantReview{RequestID: e.ID, Base: req.Base, Agent: before.Agent, Operator: operator, Before: before, After: after, Unsigned: afterEvent}, nil214}215216func (s *Service) ValidateAgentGrantReplacement(ctx context.Context, e event.Event, now int64) error {217 return s.validateGrantReplacement(ctx, s.store.DB(), e, now)218}219220func (s *Service) validateGrantReplacement(ctx context.Context, tx grantRequestReader, e event.Event, now int64) error {221 requestID := event.Tag(e, "grant-request")222 baseID := event.Tag(e, "grant-base")223 if requestID == "" || baseID == "" || !slices.Contains(event.TagValues(e, "e"), requestID) {224 return errors.New("invalid: grant replacement needs grant request provenance")225 }226 var raw string227 if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=1111`, requestID).Scan(&raw); err != nil {228 return errors.New("invalid: grant request is not stored")229 }230 request, err := event.Parse([]byte(raw))231 if err != nil {232 return err233 }234 if err := grantRequestOpen(ctx, tx, request, now); err != nil {235 return err236 }237 var grantRaw string238 if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, baseID, event.KIND_AGENT_GRANT).Scan(&grantRaw); err != nil {239 if err := tx.QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, baseID).Scan(&grantRaw); err != nil {240 return errors.New("invalid: current agent grant not found")241 }242 }243 grantEvent, err := event.Parse([]byte(grantRaw))244 if err != nil {245 return err246 }247 req, err := decodeGrantRequest(request)248 if err != nil {249 return err250 }251 review, err := s.reviewAgainstGrant(request, req, grantEvent, now)252 if err != nil || review.Base != baseID || review.Operator != e.PubKey || review.Agent != event.Tag(e, "d") {253 return errors.New("restricted: grant replacement does not match the reviewed request")254 }255 var paused int256 var revokedAt int64257 if err := tx.QueryRowContext(ctx, `SELECT paused,revoked_at FROM agent_grants WHERE agent=?`, review.Agent).Scan(&paused, &revokedAt); err != nil {258 return errors.New("conflict: agent grant is no longer current")259 }260 if paused != 0 || revokedAt != 0 || review.After.ExpiresAt <= now {261 return errors.New("restricted: grant request is no longer active")262 }263 got, err := ParseAgentGrant(e, now)264 if err != nil {265 return err266 }267 if !reflect.DeepEqual(got.Scope, review.After.Scope) || got.ExpiresAt != review.After.ExpiresAt || got.Agent != review.After.Agent || got.Owner != review.After.Owner || got.Name != review.After.Name || e.Content != grantEvent.Content {268 return errors.New("invalid: grant replacement scope differs from the reviewed request")269 }270 if !reflect.DeepEqual(e.Tags, review.Unsigned.Tags) {271 return errors.New("invalid: grant replacement tags differ from the reviewed request")272 }273 var current string274 if err := tx.QueryRowContext(ctx, `SELECT event_id FROM agent_grants WHERE agent=?`, got.Agent).Scan(&current); err != nil || current != baseID {275 return errors.New("conflict: agent grant changed since the request")276 }277 return nil278}279280func grantRequestTags(tags [][]string, changes AgentGrantChanges) [][]string {281 replaceRepo := map[string]bool{}282 for _, repo := range changes.Repos {283 replaceRepo[repo.Owner+":"+repo.Identifier] = true284 }285 replaceSite := map[string]bool{}286 for _, site := range changes.Sites {287 replaceSite[site.Label] = true288 }289 copyTags := make([][]string, 0, len(tags)+len(changes.Kinds)+len(changes.Rooms))290 seenKinds := map[string]bool{}291 seenRooms := map[string]bool{}292 remove := map[string]bool{}293 if changes.Wiki != nil {294 remove["wiki"] = true295 }296 if changes.Jobs != nil {297 remove["jobs"] = true298 }299 if changes.Rate != nil {300 remove["rate"] = true301 }302 for _, tag := range tags {303 skip := false304 if len(tag) > 1 && tag[0] == "repo" {305 parts := strings.SplitN(tag[1], ":", 3)306 skip = len(parts) == 3 && replaceRepo[parts[0]+":"+parts[1]]307 }308 if len(tag) > 1 && tag[0] == "sites" {309 skip = replaceSite[tag[1]]310 }311 if len(tag) > 0 && !remove[tag[0]] && !skip && tag[0] != "grant-request" && tag[0] != "grant-base" && !(tag[0] == "e" && len(tag) > 3 && tag[3] == "grant-request") {312 copyTags = append(copyTags, append([]string(nil), tag...))313 if len(tag) > 1 && tag[0] == "k" {314 seenKinds[tag[1]] = true315 }316 if len(tag) > 1 && tag[0] == "room" {317 seenRooms[tag[1]] = true318 }319 }320 }321 // Changes are patches: retain the old entries and add or replace only the322 // requested keys. Reconstructing these fields also gives deterministic323 // output when a request repeats an existing value.324 for _, k := range changes.Kinds {325 value := fmt.Sprint(k)326 if !seenKinds[value] {327 copyTags = append(copyTags, []string{"k", value})328 seenKinds[value] = true329 }330 }331 for _, room := range changes.Rooms {332 if !seenRooms[room] {333 copyTags = append(copyTags, []string{"room", room})334 seenRooms[room] = true335 }336 }337 for _, repo := range changes.Repos {338 copyTags = append(copyTags, []string{"repo", repo.Owner + ":" + repo.Identifier + ":" + repo.Level})339 }340 for _, site := range changes.Sites {341 tag := []string{"sites", site.Label}342 if site.TTLDays > 0 {343 tag = append(tag, fmt.Sprintf("ttl=%d", site.TTLDays))344 }345 if site.Encrypted {346 tag = append(tag, "encrypted")347 }348 copyTags = append(copyTags, tag)349 }350 if changes.Wiki != nil {351 copyTags = append(copyTags, []string{"wiki", *changes.Wiki})352 }353 if changes.Jobs != nil {354 copyTags = append(copyTags, []string{"jobs", *changes.Jobs})355 }356 if changes.Rate != nil {357 copyTags = append(copyTags, []string{"rate", fmt.Sprint(*changes.Rate)})358 }359 return copyTags360}