1package gates23// Long tasks follow NIP-90: a member or agent publishes a job request4// (kind 5000 to 5127 or 5129 to 5999), and a serving agent or member answers it with5// feedback (kind 7000) and a result (the request kind plus 1000). The relay6// keeps every event signed by its author; it only checks that requests,7// feedback and results carry the tags the protocol needs, that they are8// published by members or agents, and that an agent's answers name a9// request the relay holds.1011import (12 "context"13 "errors"14 "fmt"15 "strconv"1617 "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)2223// JobShape checks the tags of a job request, result or feedback event. It24// returns nil for other event kinds.25func 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 err34 }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 nil40}4142func jobRequestShape(e event.Event) error {43 for _, tag := range e.Tags {44 if len(tag) == 0 || tag[0] != "i" {45 continue46 }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 nil61}6263func 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 nil74}7576// jobFeature refuses job kinds while the owner has long tasks switched off.77func 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 nil82}8384// jobWriter admits job kinds from members and agents only. An agent is a85// member with the agent role, so one membership check covers both.86func 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 nil91}9293// jobReply checks a result or feedback against the request it names. When94// the relay holds the request, the result kind must match it and the p tag95// must name its author. An agent may only answer a request the relay holds96// and the agent may read; a member may also answer requests made elsewhere.97func (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 nil100 }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 missing105 }106 return nil107 }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 missing115 }116 return nil117 }118 request := result.Events[0]119 if agent && !g.CanSee(ctx, request, relay.Session{PubKeys: []string{e.PubKey}}, nil) {120 return missing121 }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 nil132}133134func jobID(value string) bool {135 return len(value) == 64 && isLowerHex(value)136}137138func jobAmount(value string) bool {139 _, err := strconv.ParseUint(value, 10, 64)140 return err == nil141}142143func isLowerHex(value string) bool {144 for _, ch := range value {145 if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') {146 return false147 }148 }149 return true150}