- commit
- e5a69f652e8317892aa6863a1d16fb2823fa9909
- parent
- 8d19b39eb7c92dd7a51b17e3ada233e5d23dd403
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 34 | +++++ |
| M | internal/ | 51 | ++++++++ |
| M | internal/ | 21 | +++ |
| A | internal/ | 147 | ++++++++++++++++++++++ |
| A | internal/ | 200 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 11 | +- |
| M | internal/ | 20 | +++ |
@@ -38,12 +38,22 @@ type AgentRepo struct {
3838 Level string `json:"level"`3939 }4040 41+// Long task grants. AgentJobsRequest lets the agent publish job requests,42+// AgentJobsServe lets it answer requests the relay holds with results and43+// feedback, and AgentJobsBoth does both.44+const (45+ AgentJobsRequest = "request"46+ AgentJobsServe = "serve"47+ AgentJobsBoth = "both"48+)49+4150 // AgentScope is the part of a grant the gate checks on every event.4251 type AgentScope struct {4352 Kinds []int `json:"kinds"`4453 Rooms []string `json:"rooms"`4554 Repos []AgentRepo `json:"repos"`4655 Wiki string `json:"wiki,omitempty"`56+ Jobs string `json:"jobs,omitempty"`4757 Rate int `json:"rate"`4858 }4959 @@ -81,9 +91,27 @@ func (g AgentGrant) AllowsKind(kind int) bool {
8191 return true8292 }8393 }94+ if event.IsJobRequest(kind) && g.RequestsJobs() {95+ return true96+ }97+ if (event.IsJobResult(kind) || kind == event.KIND_JOB_FEEDBACK) && g.ServesJobs() {98+ return true99+ }84100 return false85101 }86102 103+// RequestsJobs reports whether the jobs tag lets the agent publish job104+// requests.105+func (g AgentGrant) RequestsJobs() bool {106+ return g.Scope.Jobs == AgentJobsRequest || g.Scope.Jobs == AgentJobsBoth107+}108+109+// ServesJobs reports whether the jobs tag lets the agent answer job110+// requests with results and feedback.111+func (g AgentGrant) ServesJobs() bool {112+ return g.Scope.Jobs == AgentJobsServe || g.Scope.Jobs == AgentJobsBoth113+}114+87115 // AllowsRoom reports whether the agent may publish into a room.88116 func (g AgentGrant) AllowsRoom(room string) bool {89117 for _, allowed := range g.Scope.Rooms {@@ -238,6 +266,12 @@ func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
238266 default:239267 return AgentGrant{}, errors.New("invalid: agent grant wiki tag must be propose or edit")240268 }269+ switch jobs := event.Tag(e, "jobs"); jobs {270+ case "", AgentJobsRequest, AgentJobsServe, AgentJobsBoth:271+ grant.Scope.Jobs = jobs272+ default:273+ return AgentGrant{}, errors.New("invalid: agent grant jobs tag must be request, serve or both")274+ }241275 if value := event.Tag(e, "rate"); value != "" {242276 rate, err := strconv.Atoi(strings.TrimSpace(value))243277 if err != nil || rate < 1 || rate > AgentRateMax {@@ -58,3 +58,54 @@ const (
5858 KIND_ROOM_MEMBER_REMOVED = 441015959 KIND_AGENT_GRANT = 303926060 )61+62+// NIP-90 long tasks: a job request kind in 5000 to 5999 is answered by a63+// result kind 1000 higher and by feedback of kind 7000.64+const (65+ KIND_JOB_REQUEST_MIN = 500066+ KIND_JOB_REQUEST_MAX = 599967+ KIND_JOB_RESULT_MIN = 600068+ KIND_JOB_RESULT_MAX = 699969+ KIND_JOB_FEEDBACK = 700070+)71+72+// JobFeedbackStatuses is the NIP-90 vocabulary of the feedback status tag.73+var JobFeedbackStatuses = []string{"payment-required", "processing", "error", "success", "partial"}74+75+// JobInputTypes is the NIP-90 vocabulary of the i tag's input type.76+var JobInputTypes = []string{"url", "event", "job", "text"}77+78+func IsJobRequest(kind int) bool { return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX }79+func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX }80+81+// IsJobKind reports whether the kind is a job request, result or feedback.82+func IsJobKind(kind int) bool {83+ return IsJobRequest(kind) || IsJobResult(kind) || kind == KIND_JOB_FEEDBACK84+}85+86+// JobResultKind returns the result kind for a request kind, or 0 when the87+// kind is not a job request.88+func JobResultKind(request int) int {89+ if !IsJobRequest(request) {90+ return 091+ }92+ return request + 100093+}94+95+func IsJobFeedbackStatus(status string) bool {96+ for _, known := range JobFeedbackStatuses {97+ if known == status {98+ return true99+ }100+ }101+ return false102+}103+104+func IsJobInputType(kind string) bool {105+ for _, known := range JobInputTypes {106+ if known == kind {107+ return true108+ }109+ }110+ return false111+}@@ -81,6 +81,9 @@ func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) err
8181 if err := grant.Check(e, now); err != nil {8282 return err8383 }84+ if err := g.jobReply(ctx, e, now, true); err != nil {85+ return err86+ }8487 if !g.agents.allow(e.PubKey, grant.Scope.Rate, now) {8588 return fmt.Errorf("restricted: agent grant does not allow more than %d events per minute", grant.Scope.Rate)8689 }@@ -146,6 +149,12 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
146149 if err := nip43Shape(e, now); err != nil {147150 return err148151 }152+ if err := jobFeature(p, e); err != nil {153+ return err154+ }155+ if err := JobShape(e); err != nil {156+ return err157+ }149158 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {150159 return errors.New("invalid: event creation date is too far off from the current time")151160 }@@ -221,6 +230,12 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
221230 }222231 writeAccess = accessFor(ctx, g.cfg.Community, p, relay.Session{PubKeys: []string{principal}})223232 }233+ if err := jobWriter(e, writeAccess); err != nil {234+ return err235+ }236+ if err := g.jobReply(ctx, e, now, false); err != nil {237+ return err238+ }224239 ownerReplaceable := writeAccess.Owner && (event.IsReplaceable(e.Kind) || event.IsAddressable(e.Kind))225240 allowed, blocked, rulesErr := g.kindRules(ctx, p, e.Kind)226241 if rulesErr != nil {@@ -282,6 +297,12 @@ func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {
282297 if err := nip43Shape(e, now); err != nil {283298 return err284299 }300+ if err := jobFeature(p, e); err != nil {301+ return err302+ }303+ if err := JobShape(e); err != nil {304+ return err305+ }285306 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {286307 return errors.New("invalid: event creation date is too far off from the current time")287308 }@@ -0,0 +1,147 @@
1+package gates2+3+// Long tasks follow NIP-90: a member or agent publishes a job request4+// (kind 5000 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.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. It24+// returns nil for kinds outside the job ranges.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 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+}41+42+func 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+}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 nil74+}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 nil82+}83+84+// jobWriter admits job kinds from members and agents only. An agent is a85+// 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 nil91+}92+93+// 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.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 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 event.Tag(e, "p") != request.PubKey {126+ return errors.New("invalid: job result or feedback p tag must name the requester")127+ }128+ return nil129+}130+131+func jobID(value string) bool {132+ return len(value) == 64 && isLowerHex(value)133+}134+135+func jobAmount(value string) bool {136+ _, err := strconv.ParseUint(value, 10, 64)137+ return err == nil138+}139+140+func isLowerHex(value string) bool {141+ for _, ch := range value {142+ if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') {143+ return false144+ }145+ }146+ return true147+}@@ -0,0 +1,200 @@
1+package gates2+3+import (4+ "context"5+ "database/sql"6+ "encoding/json"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/community"11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+ "github.com/FelineStateMachine/tinyrelay/internal/relay"14+ "github.com/FelineStateMachine/tinyrelay/internal/storage"15+)16+17+const jobMemberSecret = "0000000000000000000000000000000000000000000000000000000000000005"18+19+// jobFixture adds a human member who requests jobs to the agent fixture.20+type jobFixture struct {21+ agentFixture22+ member string23+}24+25+func newJobFixture(t *testing.T) jobFixture {26+ t.Helper()27+ f := newAgentFixture(t)28+ member, _ := event.PublicKey(jobMemberSecret)29+ raw, _ := json.Marshal(member)30+ if _, err := f.community.Execute(context.Background(), f.owner, "setmember", []json.RawMessage{raw}); err != nil {31+ t.Fatal(err)32+ }33+ return jobFixture{agentFixture: f, member: member}34+}35+36+// request publishes a job request from the member and stores it, so37+// answers can name a request the relay holds.38+func (f jobFixture) request(t *testing.T, kind int, createdAt int64) event.Event {39+ t.Helper()40+ ctx := context.Background()41+ e := signed(t, jobMemberSecret, kind, createdAt, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")42+ if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {43+ t.Fatalf("member request rejected: %v", err)44+ }45+ if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {46+ _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: agentNow})47+ return err48+ }); err != nil {49+ t.Fatal(err)50+ }51+ return e52+}53+54+func expectError(t *testing.T, err error, prefix, fragment string) {55+ t.Helper()56+ if err == nil || !strings.HasPrefix(err.Error(), prefix) || !strings.Contains(err.Error(), fragment) {57+ t.Fatalf("want %s error containing %q, got %v", prefix, fragment, err)58+ }59+}60+61+func TestJobShapeRefusesMalformedEvents(t *testing.T) {62+ f := newJobFixture(t)63+ ctx := context.Background()64+ session := relay.Session{PubKeys: []string{f.member}}65+ request := f.request(t, 5001, agentNow)66+ cases := []struct {67+ name string68+ kind int69+ tags [][]string70+ fragment string71+ }{72+ {"input without a type", 5001, [][]string{{"i", "hello"}}, "url, event, job or text"},73+ {"input with an unknown type", 5001, [][]string{{"i", "hello", "voice"}}, "url, event, job or text"},74+ {"event input without an id", 5001, [][]string{{"i", "abc", "event"}}, "event id"},75+ {"encrypted without a provider", 5001, [][]string{{"encrypted"}}, "p tag"},76+ {"bid that is not an amount", 5001, [][]string{{"bid", "ten"}}, "millisats"},77+ {"result without a request", 6001, [][]string{{"p", f.member}}, "e tag"},78+ {"result without a requester", 6001, [][]string{{"e", request.ID}}, "p tag"},79+ {"result with a bad amount", 6001, [][]string{{"e", request.ID}, {"p", f.member}, {"amount", "-1"}}, "millisats"},80+ {"result of the wrong kind", 6002, [][]string{{"e", request.ID}, {"p", f.member}}, "does not answer"},81+ {"result naming the wrong requester", 6001, [][]string{{"e", request.ID}, {"p", f.owner}}, "name the requester"},82+ {"feedback without a status", 7000, [][]string{{"e", request.ID}, {"p", f.member}}, "payment-required, processing, error, success or partial"},83+ {"feedback with an unknown status", 7000, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "done"}}, "payment-required, processing, error, success or partial"},84+ }85+ for _, tc := range cases {86+ err := f.gate.Write(ctx, signed(t, jobMemberSecret, tc.kind, agentNow, tc.tags, ""), session, agentNow)87+ expectError(t, err, "invalid:", tc.fragment)88+ }89+ // Well-formed answers from a member pass, including answers to a request90+ // made on another relay.91+ for _, e := range []event.Event{92+ signed(t, jobMemberSecret, 6001, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"amount", "500", "lnbc1"}}, "output"),93+ signed(t, jobMemberSecret, 7000, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "processing"}}, ""),94+ signed(t, jobMemberSecret, 6005, agentNow, [][]string{{"e", strings.Repeat("a", 64)}, {"p", f.owner}}, "elsewhere"),95+ } {96+ if err := f.gate.Write(ctx, e, session, agentNow); err != nil {97+ t.Fatalf("well-formed kind %d rejected: %v", e.Kind, err)98+ }99+ }100+ // Import applies the same shape rules.101+ expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, 7000, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "done"}}, ""), agentNow), "invalid:", "payment-required")102+}103+104+func TestJobPolicyAndMembership(t *testing.T) {105+ f := newJobFixture(t)106+ ctx := context.Background()107+ other, _ := event.PublicKey(agentOtherSecret)108+ request := signed(t, agentOtherSecret, 5001, agentNow, [][]string{{"i", "hello", "text"}}, "")109+ // The relay writes openly, but job kinds are for members and agents.110+ expectError(t, f.gate.Write(ctx, request, relay.Session{PubKeys: []string{other}}, agentNow), "restricted:", "members and agents")111+ if err := f.gate.Write(ctx, signed(t, agentOtherSecret, 1, agentNow, nil, "note"), relay.Session{PubKeys: []string{other}}, agentNow); err != nil {112+ t.Fatalf("open relay refused a note: %v", err)113+ }114+ f.request(t, 5001, agentNow)115+ if err := f.gate.Write(ctx, signed(t, agentOwnerSecret, 5002, agentNow, nil, ""), relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {116+ t.Fatalf("owner request rejected: %v", err)117+ }118+ // Switching long tasks off refuses every job kind, for members and on119+ // import alike.120+ p := f.gate.cfg.Policy()121+ p.Features.Jobs = false122+ f.gate.cfg.Policy = func() policy.Policy { return p }123+ expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, 5001, agentNow+1, nil, ""), relay.Session{PubKeys: []string{f.member}}, agentNow), "restricted:", "switched off")124+ expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, 7000, agentNow+1, [][]string{{"e", strings.Repeat("b", 64)}, {"p", f.member}, {"status", "success"}}, ""), agentNow), "restricted:", "switched off")125+ if err := f.gate.Write(ctx, signed(t, jobMemberSecret, 1, agentNow+1, nil, "still fine"), relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {126+ t.Fatalf("switching jobs off broke notes: %v", err)127+ }128+}129+130+func TestJobGrantsRequestServeAndBoth(t *testing.T) {131+ f := newJobFixture(t)132+ ctx := context.Background()133+ session := relay.Session{PubKeys: []string{f.agent}}134+ request := f.request(t, 5001, agentNow)135+ answer := func(kind int, extra ...[]string) event.Event {136+ tags := append([][]string{{"e", request.ID}, {"p", f.member}}, extra...)137+ return signed(t, agentSecret, kind, agentNow, tags, "output")138+ }139+140+ // request: 5xxx only.141+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "request"}))142+ if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, [][]string{{"i", "hello", "text"}}, ""), session, agentNow); err != nil {143+ t.Fatalf("request grant refused a request: %v", err)144+ }145+ expectRestricted(t, f.gate.Write(ctx, answer(6001), session, agentNow), "kind 6001")146+ expectRestricted(t, f.gate.Write(ctx, answer(7000, []string{"status", "processing"}), session, agentNow), "kind 7000")147+148+ // serve: 6xxx and 7000 in reply to a held request; no requests.149+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"jobs", "serve"}))150+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow), "kind 5001")151+ if err := f.gate.Write(ctx, answer(6001, []string{"amount", "500"}), session, agentNow); err != nil {152+ t.Fatalf("serve grant refused a result: %v", err)153+ }154+ if err := f.gate.Write(ctx, answer(7000, []string{"status", "payment-required"}, []string{"amount", "500", "lnbc1"}), session, agentNow); err != nil {155+ t.Fatalf("serve grant refused feedback: %v", err)156+ }157+ unknown := signed(t, agentSecret, 6001, agentNow, [][]string{{"e", strings.Repeat("c", 64)}, {"p", f.member}}, "")158+ expectRestricted(t, f.gate.Write(ctx, unknown, session, agentNow), "requests this relay holds")159+ expectRestricted(t, f.gate.Import(ctx, unknown, agentNow), "requests this relay holds")160+ // A note that is not a request cannot be answered, and an answer must161+ // match the request's kind and author.162+ note := signed(t, jobMemberSecret, 1, agentNow, nil, "not a job")163+ if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {164+ _, err := storage.SaveTx(ctx, tx, note, storage.SaveOptions{Now: agentNow})165+ return err166+ }); err != nil {167+ t.Fatal(err)168+ }169+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 6001, agentNow, [][]string{{"e", note.ID}, {"p", f.member}}, ""), session, agentNow), "requests this relay holds")170+ expectError(t, f.gate.Write(ctx, answer(6002), session, agentNow), "invalid:", "does not answer")171+ expectError(t, f.gate.Write(ctx, signed(t, agentSecret, 6001, agentNow, [][]string{{"e", request.ID}, {"p", f.owner}}, ""), session, agentNow), "invalid:", "name the requester")172+173+ // both: requests and answers.174+ f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"jobs", "both"}))175+ if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow); err != nil {176+ t.Fatalf("both grant refused a request: %v", err)177+ }178+ if err := f.gate.Write(ctx, answer(6001), session, agentNow); err != nil {179+ t.Fatalf("both grant refused a result: %v", err)180+ }181+ // A k tag still covers a single job kind, and answers from a k grant182+ // name a held request as well.183+ f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"k", "5001"}, []string{"k", "6001"}))184+ if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow); err != nil {185+ t.Fatalf("k grant refused a request: %v", err)186+ }187+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 5002, agentNow, nil, ""), session, agentNow), "kind 5002")188+ if err := f.gate.Write(ctx, answer(6001), session, agentNow); err != nil {189+ t.Fatalf("k grant refused a result: %v", err)190+ }191+ expectRestricted(t, f.gate.Write(ctx, unknown, session, agentNow), "requests this relay holds")192+ // The jobs tag is bounded.193+ if _, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "all"}), ""), agentNow); err == nil || !strings.Contains(err.Error(), "request, serve or both") {194+ t.Fatalf("jobs tag accepted an unknown value: %v", err)195+ }196+ grant, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "serve"}), ""), agentNow)197+ if err != nil || grant.Scope.Jobs != community.AgentJobsServe || !grant.ServesJobs() || grant.RequestsJobs() {198+ t.Fatalf("serve grant parsed as %+v: %v", grant.Scope, err)199+ }200+}@@ -52,6 +52,9 @@ type Features struct {
5252 // not a repository visibility hint: all reads require current membership.5353 Grasp08 bool `json:"grasp08"`5454 Push bool `json:"push"`55+ // Jobs admits NIP-90 job requests, results and feedback from members56+ // and agents. It is on unless the owner switches it off.57+ Jobs bool `json:"jobs"`5558 }5659 5760 type Delivery struct {@@ -132,11 +135,13 @@ type Policy struct {
132135 // DefaultRooms is the room allowance applied when a policy does not name one.133136 const DefaultRooms = 64134137 135-// UnmarshalJSON keeps the room allowance at its default when a stored policy136-// predates the field, so existing tenants do not lose room creation.138+// UnmarshalJSON keeps the room allowance and the long task switch at their139+// defaults when a stored policy predates the fields, so existing tenants do140+// not lose room creation or job traffic.137141 func (p *Policy) UnmarshalJSON(data []byte) error {138142 type plain Policy139143 decoded := plain{Rooms: DefaultRooms}144+ decoded.Features.Jobs = true140145 if err := json.Unmarshal(data, &decoded); err != nil {141146 return err142147 }@@ -146,7 +151,7 @@ func (p *Policy) UnmarshalJSON(data []byte) error {
146151 147152 func Defaults(owner string) Policy {148153 return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7, Rooms: DefaultRooms,149- Features: Features{Search: "prose", Sync: true, Count: true, Discovery: true, Names: true, Files: true, Pages: true, Signer: true, Sites: Sites{Enabled: true, Mirror: true}},154+ Features: Features{Search: "prose", Sync: true, Count: true, Discovery: true, Names: true, Files: true, Pages: true, Signer: true, Sites: Sites{Enabled: true, Mirror: true}, Jobs: true},150155 Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}}151156 }152157 @@ -24,6 +24,26 @@ func TestPatchOmittedAndEmptySections(t *testing.T) {
2424 }2525 }2626 27+func TestJobsDefaultOnUntilSwitchedOff(t *testing.T) {28+ if !Defaults("owner").Features.Jobs {29+ t.Fatal("long tasks are off by default")30+ }31+ var stored Policy32+ if err := json.Unmarshal([]byte(`{"owner":"owner","writes":"open","reads":"open","features":{"search":"prose"}}`), &stored); err != nil {33+ t.Fatal(err)34+ }35+ if !stored.Features.Jobs {36+ t.Fatal("a stored policy without the field lost long tasks")37+ }38+ patched, err := Patch(Defaults("owner"), map[string]json.RawMessage{"features": json.RawMessage(`{"jobs":false}`)})39+ if err != nil {40+ t.Fatal(err)41+ }42+ if patched.Features.Jobs || patched.Features.Search != "prose" {43+ t.Fatalf("patch did not switch long tasks off: %#v", patched.Features)44+ }45+}46+2747 func TestPrivateReadRequiresRecipient(t *testing.T) {2848 p := Defaults("owner")2949 p.Reads = "members"