| 1 | package gates |
| 2 | |
| 3 | // Long tasks follow NIP-90: a member or agent publishes a job request |
| 4 | // (kind 5000 to 5127 or 5129 to 5999), and a serving agent or member answers it with |
| 5 | // feedback (kind 7000) and a result (the request kind plus 1000). The relay |
| 6 | // keeps every event signed by its author; it only checks that requests, |
| 7 | // feedback and results carry the tags the protocol needs, that they are |
| 8 | // published by members or agents, and that an agent's answers name a |
| 9 | // request the relay holds. |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "errors" |
| 14 | "fmt" |
| 15 | "strconv" |
| 16 | |
| 17 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 18 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 19 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 20 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 21 | ) |
| 22 | |
| 23 | // JobShape checks the tags of a job request, result or feedback event. It |
| 24 | // returns nil for other event kinds. |
| 25 | func JobShape(e event.Event) error { |
| 26 | switch { |
| 27 | case event.IsJobRequest(e.Kind): |
| 28 | return jobRequestShape(e) |
| 29 | case event.IsJobResult(e.Kind): |
| 30 | return jobReplyShape(e, "job result" ) |
| 31 | case e.Kind == event.KIND_JOB_FEEDBACK: |
| 32 | if err := jobReplyShape(e, "job feedback" ); err != nil { |
| 33 | return err |
| 34 | } |
| 35 | if !event.IsJobFeedbackStatus(event.Tag(e, "status" )) { |
| 36 | return errors.New("invalid: job feedback status must be payment-required, processing, error, success or partial" ) |
| 37 | } |
| 38 | } |
| 39 | return nil |
| 40 | } |
| 41 | |
| 42 | func jobRequestShape(e event.Event) error { |
| 43 | for _, tag := range e.Tags { |
| 44 | if len(tag) == 0 || tag[0] != "i" { |
| 45 | continue |
| 46 | } |
| 47 | if len(tag) < 3 || !event.IsJobInputType(tag[2]) { |
| 48 | return errors.New("invalid: job input i tag needs data and a type of url, event, job or text" ) |
| 49 | } |
| 50 | if (tag[2] == "event" || tag[2] == "job" ) && !jobID(tag[1]) { |
| 51 | return errors.New("invalid: job input of type event or job must name an event id" ) |
| 52 | } |
| 53 | } |
| 54 | if hasTag(e, "encrypted" ) && !jobID(event.Tag(e, "p" )) { |
| 55 | return errors.New("invalid: encrypted job request must name the service provider in a p tag" ) |
| 56 | } |
| 57 | if bid := event.Tag(e, "bid" ); bid != "" && !jobAmount(bid) { |
| 58 | return errors.New("invalid: job bid must be an amount in millisats" ) |
| 59 | } |
| 60 | return nil |
| 61 | } |
| 62 | |
| 63 | func jobReplyShape(e event.Event, what string) error { |
| 64 | if !jobID(event.Tag(e, "e" )) { |
| 65 | return fmt.Errorf("invalid: %s must name the job request in an e tag" , what) |
| 66 | } |
| 67 | if !jobID(event.Tag(e, "p" )) { |
| 68 | return fmt.Errorf("invalid: %s must name the requester in a p tag" , what) |
| 69 | } |
| 70 | if amount := event.Tag(e, "amount" ); amount != "" && !jobAmount(amount) { |
| 71 | return fmt.Errorf("invalid: %s amount must be in millisats" , what) |
| 72 | } |
| 73 | return nil |
| 74 | } |
| 75 | |
| 76 | // jobFeature refuses job kinds while the owner has long tasks switched off. |
| 77 | func jobFeature(p policy.Policy, e event.Event) error { |
| 78 | if event.IsJobKind(e.Kind) && !p.Features.Jobs { |
| 79 | return errors.New("restricted: long tasks are switched off on this relay" ) |
| 80 | } |
| 81 | return nil |
| 82 | } |
| 83 | |
| 84 | // jobWriter admits job kinds from members and agents only. An agent is a |
| 85 | // member with the agent role, so one membership check covers both. |
| 86 | func jobWriter(e event.Event, a policy.Access) error { |
| 87 | if event.IsJobKind(e.Kind) && !a.Member && !a.Owner { |
| 88 | return errors.New("restricted: long tasks are accepted from members and agents" ) |
| 89 | } |
| 90 | return nil |
| 91 | } |
| 92 | |
| 93 | // jobReply checks a result or feedback against the request it names. When |
| 94 | // the relay holds the request, the result kind must match it and the p tag |
| 95 | // must name its author. An agent may only answer a request the relay holds |
| 96 | // and the agent may read; a member may also answer requests made elsewhere. |
| 97 | func (g *Gate) jobReply(ctx context.Context, e event.Event, now int64, agent bool) error { |
| 98 | if !event.IsJobResult(e.Kind) && e.Kind != event.KIND_JOB_FEEDBACK { |
| 99 | return nil |
| 100 | } |
| 101 | missing := errors.New("restricted: agent grant allows job results and feedback only for requests this relay holds" ) |
| 102 | if g.cfg.Store == nil { |
| 103 | if agent { |
| 104 | return missing |
| 105 | } |
| 106 | return nil |
| 107 | } |
| 108 | result, err := g.cfg.Store.Query(ctx, event.Filter{IDs: []string{event.Tag(e, "e" )}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 1}) |
| 109 | if err != nil { |
| 110 | return fmt.Errorf("check job request: %w" , err) |
| 111 | } |
| 112 | if len(result.Events) == 0 || !event.IsJobRequest(result.Events[0].Kind) { |
| 113 | if agent { |
| 114 | return missing |
| 115 | } |
| 116 | return nil |
| 117 | } |
| 118 | request := result.Events[0] |
| 119 | if agent && !g.CanSee(ctx, request, relay.Session{PubKeys: []string{e.PubKey}}, nil) { |
| 120 | return missing |
| 121 | } |
| 122 | if event.IsJobResult(e.Kind) && e.Kind != event.JobResultKind(request.Kind) { |
| 123 | return fmt.Errorf("invalid: job result kind %d does not answer a kind %d request" , e.Kind, request.Kind) |
| 124 | } |
| 125 | if room := event.Tag(request, "h" ); room != "" && event.Tag(e, "h" ) != room { |
| 126 | return errors.New("invalid: job result or feedback must keep the request's room h tag" ) |
| 127 | } |
| 128 | if event.Tag(e, "p" ) != request.PubKey { |
| 129 | return errors.New("invalid: job result or feedback p tag must name the requester" ) |
| 130 | } |
| 131 | return nil |
| 132 | } |
| 133 | |
| 134 | func jobID(value string) bool { |
| 135 | return len(value) == 64 && isLowerHex(value) |
| 136 | } |
| 137 | |
| 138 | func jobAmount(value string) bool { |
| 139 | _, err := strconv.ParseUint(value, 10, 64) |
| 140 | return err == nil |
| 141 | } |
| 142 | |
| 143 | func isLowerHex(value string) bool { |
| 144 | for _, ch := range value { |
| 145 | if (ch < '0' || ch > '9' ) && (ch < 'a' || ch > 'f' ) { |
| 146 | return false |
| 147 | } |
| 148 | } |
| 149 | return true |
| 150 | } |