tinyrelaycommit

Commit e5a69f652e83

browse files at this commit | history

1diff --git a/internal/community/agents.go b/internal/community/agents.go
2index 32560e2..e9cb0e1 100644
3--- a/internal/community/agents.go
4+++ b/internal/community/agents.go
5@@ -38,12 +38,22 @@ type AgentRepo struct {
6 Level string `json:"level"`
7 }
8
9+// Long task grants. AgentJobsRequest lets the agent publish job requests,
10+// AgentJobsServe lets it answer requests the relay holds with results and
11+// feedback, and AgentJobsBoth does both.
12+const (
13+ AgentJobsRequest = "request"
14+ AgentJobsServe = "serve"
15+ AgentJobsBoth = "both"
16+)
17+
18 // AgentScope is the part of a grant the gate checks on every event.
19 type AgentScope struct {
20 Kinds []int `json:"kinds"`
21 Rooms []string `json:"rooms"`
22 Repos []AgentRepo `json:"repos"`
23 Wiki string `json:"wiki,omitempty"`
24+ Jobs string `json:"jobs,omitempty"`
25 Rate int `json:"rate"`
26 }
27
28@@ -81,9 +91,27 @@ func (g AgentGrant) AllowsKind(kind int) bool {
29 return true
30 }
31 }
32+ if event.IsJobRequest(kind) && g.RequestsJobs() {
33+ return true
34+ }
35+ if (event.IsJobResult(kind) || kind == event.KIND_JOB_FEEDBACK) && g.ServesJobs() {
36+ return true
37+ }
38 return false
39 }
40
41+// RequestsJobs reports whether the jobs tag lets the agent publish job
42+// requests.
43+func (g AgentGrant) RequestsJobs() bool {
44+ return g.Scope.Jobs == AgentJobsRequest || g.Scope.Jobs == AgentJobsBoth
45+}
46+
47+// ServesJobs reports whether the jobs tag lets the agent answer job
48+// requests with results and feedback.
49+func (g AgentGrant) ServesJobs() bool {
50+ return g.Scope.Jobs == AgentJobsServe || g.Scope.Jobs == AgentJobsBoth
51+}
52+
53 // AllowsRoom reports whether the agent may publish into a room.
54 func (g AgentGrant) AllowsRoom(room string) bool {
55 for _, allowed := range g.Scope.Rooms {
56@@ -238,6 +266,12 @@ func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
57 default:
58 return AgentGrant{}, errors.New("invalid: agent grant wiki tag must be propose or edit")
59 }
60+ switch jobs := event.Tag(e, "jobs"); jobs {
61+ case "", AgentJobsRequest, AgentJobsServe, AgentJobsBoth:
62+ grant.Scope.Jobs = jobs
63+ default:
64+ return AgentGrant{}, errors.New("invalid: agent grant jobs tag must be request, serve or both")
65+ }
66 if value := event.Tag(e, "rate"); value != "" {
67 rate, err := strconv.Atoi(strings.TrimSpace(value))
68 if err != nil || rate < 1 || rate > AgentRateMax {
69diff --git a/internal/event/kinds.go b/internal/event/kinds.go
70index 4405f49..f8a5b51 100644
71--- a/internal/event/kinds.go
72+++ b/internal/event/kinds.go
73@@ -58,3 +58,54 @@ const (
74 KIND_ROOM_MEMBER_REMOVED = 44101
75 KIND_AGENT_GRANT = 30392
76 )
77+
78+// NIP-90 long tasks: a job request kind in 5000 to 5999 is answered by a
79+// result kind 1000 higher and by feedback of kind 7000.
80+const (
81+ KIND_JOB_REQUEST_MIN = 5000
82+ KIND_JOB_REQUEST_MAX = 5999
83+ KIND_JOB_RESULT_MIN = 6000
84+ KIND_JOB_RESULT_MAX = 6999
85+ KIND_JOB_FEEDBACK = 7000
86+)
87+
88+// JobFeedbackStatuses is the NIP-90 vocabulary of the feedback status tag.
89+var JobFeedbackStatuses = []string{"payment-required", "processing", "error", "success", "partial"}
90+
91+// JobInputTypes is the NIP-90 vocabulary of the i tag's input type.
92+var JobInputTypes = []string{"url", "event", "job", "text"}
93+
94+func IsJobRequest(kind int) bool { return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX }
95+func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX }
96+
97+// IsJobKind reports whether the kind is a job request, result or feedback.
98+func IsJobKind(kind int) bool {
99+ return IsJobRequest(kind) || IsJobResult(kind) || kind == KIND_JOB_FEEDBACK
100+}
101+
102+// JobResultKind returns the result kind for a request kind, or 0 when the
103+// kind is not a job request.
104+func JobResultKind(request int) int {
105+ if !IsJobRequest(request) {
106+ return 0
107+ }
108+ return request + 1000
109+}
110+
111+func IsJobFeedbackStatus(status string) bool {
112+ for _, known := range JobFeedbackStatuses {
113+ if known == status {
114+ return true
115+ }
116+ }
117+ return false
118+}
119+
120+func IsJobInputType(kind string) bool {
121+ for _, known := range JobInputTypes {
122+ if known == kind {
123+ return true
124+ }
125+ }
126+ return false
127+}
128diff --git a/internal/gates/gates.go b/internal/gates/gates.go
129index 2837a4f..82e41a8 100644
130--- a/internal/gates/gates.go
131+++ b/internal/gates/gates.go
132@@ -81,6 +81,9 @@ func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) err
133 if err := grant.Check(e, now); err != nil {
134 return err
135 }
136+ if err := g.jobReply(ctx, e, now, true); err != nil {
137+ return err
138+ }
139 if !g.agents.allow(e.PubKey, grant.Scope.Rate, now) {
140 return fmt.Errorf("restricted: agent grant does not allow more than %d events per minute", grant.Scope.Rate)
141 }
142@@ -146,6 +149,12 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
143 if err := nip43Shape(e, now); err != nil {
144 return err
145 }
146+ if err := jobFeature(p, e); err != nil {
147+ return err
148+ }
149+ if err := JobShape(e); err != nil {
150+ return err
151+ }
152 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {
153 return errors.New("invalid: event creation date is too far off from the current time")
154 }
155@@ -221,6 +230,12 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
156 }
157 writeAccess = accessFor(ctx, g.cfg.Community, p, relay.Session{PubKeys: []string{principal}})
158 }
159+ if err := jobWriter(e, writeAccess); err != nil {
160+ return err
161+ }
162+ if err := g.jobReply(ctx, e, now, false); err != nil {
163+ return err
164+ }
165 ownerReplaceable := writeAccess.Owner && (event.IsReplaceable(e.Kind) || event.IsAddressable(e.Kind))
166 allowed, blocked, rulesErr := g.kindRules(ctx, p, e.Kind)
167 if rulesErr != nil {
168@@ -282,6 +297,12 @@ func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {
169 if err := nip43Shape(e, now); err != nil {
170 return err
171 }
172+ if err := jobFeature(p, e); err != nil {
173+ return err
174+ }
175+ if err := JobShape(e); err != nil {
176+ return err
177+ }
178 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {
179 return errors.New("invalid: event creation date is too far off from the current time")
180 }
181diff --git a/internal/gates/jobs.go b/internal/gates/jobs.go
182new file mode 100644
183index 0000000..c6a85de
184--- /dev/null
185+++ b/internal/gates/jobs.go
186@@ -0,0 +1,147 @@
187+package gates
188+
189+// Long tasks follow NIP-90: a member or agent publishes a job request
190+// (kind 5000 to 5999), and a serving agent or member answers it with
191+// feedback (kind 7000) and a result (the request kind plus 1000). The relay
192+// keeps every event signed by its author; it only checks that requests,
193+// feedback and results carry the tags the protocol needs, that they are
194+// published by members or agents, and that an agent's answers name a
195+// request the relay holds.
196+
197+import (
198+ "context"
199+ "errors"
200+ "fmt"
201+ "strconv"
202+
203+ "github.com/FelineStateMachine/tinyrelay/internal/event"
204+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
205+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
206+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
207+)
208+
209+// JobShape checks the tags of a job request, result or feedback event. It
210+// returns nil for kinds outside the job ranges.
211+func JobShape(e event.Event) error {
212+ switch {
213+ case event.IsJobRequest(e.Kind):
214+ return jobRequestShape(e)
215+ case event.IsJobResult(e.Kind):
216+ return jobReplyShape(e, "job result")
217+ case e.Kind == event.KIND_JOB_FEEDBACK:
218+ if err := jobReplyShape(e, "job feedback"); err != nil {
219+ return err
220+ }
221+ if !event.IsJobFeedbackStatus(event.Tag(e, "status")) {
222+ return errors.New("invalid: job feedback status must be payment-required, processing, error, success or partial")
223+ }
224+ }
225+ return nil
226+}
227+
228+func jobRequestShape(e event.Event) error {
229+ for _, tag := range e.Tags {
230+ if len(tag) == 0 || tag[0] != "i" {
231+ continue
232+ }
233+ if len(tag) < 3 || !event.IsJobInputType(tag[2]) {
234+ return errors.New("invalid: job input i tag needs data and a type of url, event, job or text")
235+ }
236+ if (tag[2] == "event" || tag[2] == "job") && !jobID(tag[1]) {
237+ return errors.New("invalid: job input of type event or job must name an event id")
238+ }
239+ }
240+ if hasTag(e, "encrypted") && !jobID(event.Tag(e, "p")) {
241+ return errors.New("invalid: encrypted job request must name the service provider in a p tag")
242+ }
243+ if bid := event.Tag(e, "bid"); bid != "" && !jobAmount(bid) {
244+ return errors.New("invalid: job bid must be an amount in millisats")
245+ }
246+ return nil
247+}
248+
249+func jobReplyShape(e event.Event, what string) error {
250+ if !jobID(event.Tag(e, "e")) {
251+ return fmt.Errorf("invalid: %s must name the job request in an e tag", what)
252+ }
253+ if !jobID(event.Tag(e, "p")) {
254+ return fmt.Errorf("invalid: %s must name the requester in a p tag", what)
255+ }
256+ if amount := event.Tag(e, "amount"); amount != "" && !jobAmount(amount) {
257+ return fmt.Errorf("invalid: %s amount must be in millisats", what)
258+ }
259+ return nil
260+}
261+
262+// jobFeature refuses job kinds while the owner has long tasks switched off.
263+func jobFeature(p policy.Policy, e event.Event) error {
264+ if event.IsJobKind(e.Kind) && !p.Features.Jobs {
265+ return errors.New("restricted: long tasks are switched off on this relay")
266+ }
267+ return nil
268+}
269+
270+// jobWriter admits job kinds from members and agents only. An agent is a
271+// member with the agent role, so one membership check covers both.
272+func jobWriter(e event.Event, a policy.Access) error {
273+ if event.IsJobKind(e.Kind) && !a.Member && !a.Owner {
274+ return errors.New("restricted: long tasks are accepted from members and agents")
275+ }
276+ return nil
277+}
278+
279+// jobReply checks a result or feedback against the request it names. When
280+// the relay holds the request, the result kind must match it and the p tag
281+// must name its author. An agent may only answer a request the relay holds
282+// and the agent may read; a member may also answer requests made elsewhere.
283+func (g *Gate) jobReply(ctx context.Context, e event.Event, now int64, agent bool) error {
284+ if !event.IsJobResult(e.Kind) && e.Kind != event.KIND_JOB_FEEDBACK {
285+ return nil
286+ }
287+ missing := errors.New("restricted: agent grant allows job results and feedback only for requests this relay holds")
288+ if g.cfg.Store == nil {
289+ if agent {
290+ return missing
291+ }
292+ return nil
293+ }
294+ 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})
295+ if err != nil {
296+ return fmt.Errorf("check job request: %w", err)
297+ }
298+ if len(result.Events) == 0 || !event.IsJobRequest(result.Events[0].Kind) {
299+ if agent {
300+ return missing
301+ }
302+ return nil
303+ }
304+ request := result.Events[0]
305+ if agent && !g.CanSee(ctx, request, relay.Session{PubKeys: []string{e.PubKey}}, nil) {
306+ return missing
307+ }
308+ if event.IsJobResult(e.Kind) && e.Kind != event.JobResultKind(request.Kind) {
309+ return fmt.Errorf("invalid: job result kind %d does not answer a kind %d request", e.Kind, request.Kind)
310+ }
311+ if event.Tag(e, "p") != request.PubKey {
312+ return errors.New("invalid: job result or feedback p tag must name the requester")
313+ }
314+ return nil
315+}
316+
317+func jobID(value string) bool {
318+ return len(value) == 64 && isLowerHex(value)
319+}
320+
321+func jobAmount(value string) bool {
322+ _, err := strconv.ParseUint(value, 10, 64)
323+ return err == nil
324+}
325+
326+func isLowerHex(value string) bool {
327+ for _, ch := range value {
328+ if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') {
329+ return false
330+ }
331+ }
332+ return true
333+}
334diff --git a/internal/gates/jobs_test.go b/internal/gates/jobs_test.go
335new file mode 100644
336index 0000000..2b930a3
337--- /dev/null
338+++ b/internal/gates/jobs_test.go
339@@ -0,0 +1,200 @@
340+package gates
341+
342+import (
343+ "context"
344+ "database/sql"
345+ "encoding/json"
346+ "strings"
347+ "testing"
348+
349+ "github.com/FelineStateMachine/tinyrelay/internal/community"
350+ "github.com/FelineStateMachine/tinyrelay/internal/event"
351+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
352+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
353+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
354+)
355+
356+const jobMemberSecret = "0000000000000000000000000000000000000000000000000000000000000005"
357+
358+// jobFixture adds a human member who requests jobs to the agent fixture.
359+type jobFixture struct {
360+ agentFixture
361+ member string
362+}
363+
364+func newJobFixture(t *testing.T) jobFixture {
365+ t.Helper()
366+ f := newAgentFixture(t)
367+ member, _ := event.PublicKey(jobMemberSecret)
368+ raw, _ := json.Marshal(member)
369+ if _, err := f.community.Execute(context.Background(), f.owner, "setmember", []json.RawMessage{raw}); err != nil {
370+ t.Fatal(err)
371+ }
372+ return jobFixture{agentFixture: f, member: member}
373+}
374+
375+// request publishes a job request from the member and stores it, so
376+// answers can name a request the relay holds.
377+func (f jobFixture) request(t *testing.T, kind int, createdAt int64) event.Event {
378+ t.Helper()
379+ ctx := context.Background()
380+ e := signed(t, jobMemberSecret, kind, createdAt, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")
381+ if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {
382+ t.Fatalf("member request rejected: %v", err)
383+ }
384+ if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {
385+ _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: agentNow})
386+ return err
387+ }); err != nil {
388+ t.Fatal(err)
389+ }
390+ return e
391+}
392+
393+func expectError(t *testing.T, err error, prefix, fragment string) {
394+ t.Helper()
395+ if err == nil || !strings.HasPrefix(err.Error(), prefix) || !strings.Contains(err.Error(), fragment) {
396+ t.Fatalf("want %s error containing %q, got %v", prefix, fragment, err)
397+ }
398+}
399+
400+func TestJobShapeRefusesMalformedEvents(t *testing.T) {
401+ f := newJobFixture(t)
402+ ctx := context.Background()
403+ session := relay.Session{PubKeys: []string{f.member}}
404+ request := f.request(t, 5001, agentNow)
405+ cases := []struct {
406+ name string
407+ kind int
408+ tags [][]string
409+ fragment string
410+ }{
411+ {"input without a type", 5001, [][]string{{"i", "hello"}}, "url, event, job or text"},
412+ {"input with an unknown type", 5001, [][]string{{"i", "hello", "voice"}}, "url, event, job or text"},
413+ {"event input without an id", 5001, [][]string{{"i", "abc", "event"}}, "event id"},
414+ {"encrypted without a provider", 5001, [][]string{{"encrypted"}}, "p tag"},
415+ {"bid that is not an amount", 5001, [][]string{{"bid", "ten"}}, "millisats"},
416+ {"result without a request", 6001, [][]string{{"p", f.member}}, "e tag"},
417+ {"result without a requester", 6001, [][]string{{"e", request.ID}}, "p tag"},
418+ {"result with a bad amount", 6001, [][]string{{"e", request.ID}, {"p", f.member}, {"amount", "-1"}}, "millisats"},
419+ {"result of the wrong kind", 6002, [][]string{{"e", request.ID}, {"p", f.member}}, "does not answer"},
420+ {"result naming the wrong requester", 6001, [][]string{{"e", request.ID}, {"p", f.owner}}, "name the requester"},
421+ {"feedback without a status", 7000, [][]string{{"e", request.ID}, {"p", f.member}}, "payment-required, processing, error, success or partial"},
422+ {"feedback with an unknown status", 7000, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "done"}}, "payment-required, processing, error, success or partial"},
423+ }
424+ for _, tc := range cases {
425+ err := f.gate.Write(ctx, signed(t, jobMemberSecret, tc.kind, agentNow, tc.tags, ""), session, agentNow)
426+ expectError(t, err, "invalid:", tc.fragment)
427+ }
428+ // Well-formed answers from a member pass, including answers to a request
429+ // made on another relay.
430+ for _, e := range []event.Event{
431+ signed(t, jobMemberSecret, 6001, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"amount", "500", "lnbc1"}}, "output"),
432+ signed(t, jobMemberSecret, 7000, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "processing"}}, ""),
433+ signed(t, jobMemberSecret, 6005, agentNow, [][]string{{"e", strings.Repeat("a", 64)}, {"p", f.owner}}, "elsewhere"),
434+ } {
435+ if err := f.gate.Write(ctx, e, session, agentNow); err != nil {
436+ t.Fatalf("well-formed kind %d rejected: %v", e.Kind, err)
437+ }
438+ }
439+ // Import applies the same shape rules.
440+ expectError(t, f.gate.Import(ctx, signed(t, jobMemberSecret, 7000, agentNow, [][]string{{"e", request.ID}, {"p", f.member}, {"status", "done"}}, ""), agentNow), "invalid:", "payment-required")
441+}
442+
443+func TestJobPolicyAndMembership(t *testing.T) {
444+ f := newJobFixture(t)
445+ ctx := context.Background()
446+ other, _ := event.PublicKey(agentOtherSecret)
447+ request := signed(t, agentOtherSecret, 5001, agentNow, [][]string{{"i", "hello", "text"}}, "")
448+ // The relay writes openly, but job kinds are for members and agents.
449+ expectError(t, f.gate.Write(ctx, request, relay.Session{PubKeys: []string{other}}, agentNow), "restricted:", "members and agents")
450+ if err := f.gate.Write(ctx, signed(t, agentOtherSecret, 1, agentNow, nil, "note"), relay.Session{PubKeys: []string{other}}, agentNow); err != nil {
451+ t.Fatalf("open relay refused a note: %v", err)
452+ }
453+ f.request(t, 5001, agentNow)
454+ if err := f.gate.Write(ctx, signed(t, agentOwnerSecret, 5002, agentNow, nil, ""), relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {
455+ t.Fatalf("owner request rejected: %v", err)
456+ }
457+ // Switching long tasks off refuses every job kind, for members and on
458+ // import alike.
459+ p := f.gate.cfg.Policy()
460+ p.Features.Jobs = false
461+ f.gate.cfg.Policy = func() policy.Policy { return p }
462+ expectError(t, f.gate.Write(ctx, signed(t, jobMemberSecret, 5001, agentNow+1, nil, ""), relay.Session{PubKeys: []string{f.member}}, agentNow), "restricted:", "switched off")
463+ 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")
464+ if err := f.gate.Write(ctx, signed(t, jobMemberSecret, 1, agentNow+1, nil, "still fine"), relay.Session{PubKeys: []string{f.member}}, agentNow); err != nil {
465+ t.Fatalf("switching jobs off broke notes: %v", err)
466+ }
467+}
468+
469+func TestJobGrantsRequestServeAndBoth(t *testing.T) {
470+ f := newJobFixture(t)
471+ ctx := context.Background()
472+ session := relay.Session{PubKeys: []string{f.agent}}
473+ request := f.request(t, 5001, agentNow)
474+ answer := func(kind int, extra ...[]string) event.Event {
475+ tags := append([][]string{{"e", request.ID}, {"p", f.member}}, extra...)
476+ return signed(t, agentSecret, kind, agentNow, tags, "output")
477+ }
478+
479+ // request: 5xxx only.
480+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "request"}))
481+ if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, [][]string{{"i", "hello", "text"}}, ""), session, agentNow); err != nil {
482+ t.Fatalf("request grant refused a request: %v", err)
483+ }
484+ expectRestricted(t, f.gate.Write(ctx, answer(6001), session, agentNow), "kind 6001")
485+ expectRestricted(t, f.gate.Write(ctx, answer(7000, []string{"status", "processing"}), session, agentNow), "kind 7000")
486+
487+ // serve: 6xxx and 7000 in reply to a held request; no requests.
488+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"jobs", "serve"}))
489+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow), "kind 5001")
490+ if err := f.gate.Write(ctx, answer(6001, []string{"amount", "500"}), session, agentNow); err != nil {
491+ t.Fatalf("serve grant refused a result: %v", err)
492+ }
493+ if err := f.gate.Write(ctx, answer(7000, []string{"status", "payment-required"}, []string{"amount", "500", "lnbc1"}), session, agentNow); err != nil {
494+ t.Fatalf("serve grant refused feedback: %v", err)
495+ }
496+ unknown := signed(t, agentSecret, 6001, agentNow, [][]string{{"e", strings.Repeat("c", 64)}, {"p", f.member}}, "")
497+ expectRestricted(t, f.gate.Write(ctx, unknown, session, agentNow), "requests this relay holds")
498+ expectRestricted(t, f.gate.Import(ctx, unknown, agentNow), "requests this relay holds")
499+ // A note that is not a request cannot be answered, and an answer must
500+ // match the request's kind and author.
501+ note := signed(t, jobMemberSecret, 1, agentNow, nil, "not a job")
502+ if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {
503+ _, err := storage.SaveTx(ctx, tx, note, storage.SaveOptions{Now: agentNow})
504+ return err
505+ }); err != nil {
506+ t.Fatal(err)
507+ }
508+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 6001, agentNow, [][]string{{"e", note.ID}, {"p", f.member}}, ""), session, agentNow), "requests this relay holds")
509+ expectError(t, f.gate.Write(ctx, answer(6002), session, agentNow), "invalid:", "does not answer")
510+ expectError(t, f.gate.Write(ctx, signed(t, agentSecret, 6001, agentNow, [][]string{{"e", request.ID}, {"p", f.owner}}, ""), session, agentNow), "invalid:", "name the requester")
511+
512+ // both: requests and answers.
513+ f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"jobs", "both"}))
514+ if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow); err != nil {
515+ t.Fatalf("both grant refused a request: %v", err)
516+ }
517+ if err := f.gate.Write(ctx, answer(6001), session, agentNow); err != nil {
518+ t.Fatalf("both grant refused a result: %v", err)
519+ }
520+ // A k tag still covers a single job kind, and answers from a k grant
521+ // name a held request as well.
522+ f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"k", "5001"}, []string{"k", "6001"}))
523+ if err := f.gate.Write(ctx, signed(t, agentSecret, 5001, agentNow, nil, ""), session, agentNow); err != nil {
524+ t.Fatalf("k grant refused a request: %v", err)
525+ }
526+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 5002, agentNow, nil, ""), session, agentNow), "kind 5002")
527+ if err := f.gate.Write(ctx, answer(6001), session, agentNow); err != nil {
528+ t.Fatalf("k grant refused a result: %v", err)
529+ }
530+ expectRestricted(t, f.gate.Write(ctx, unknown, session, agentNow), "requests this relay holds")
531+ // The jobs tag is bounded.
532+ 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") {
533+ t.Fatalf("jobs tag accepted an unknown value: %v", err)
534+ }
535+ grant, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"jobs", "serve"}), ""), agentNow)
536+ if err != nil || grant.Scope.Jobs != community.AgentJobsServe || !grant.ServesJobs() || grant.RequestsJobs() {
537+ t.Fatalf("serve grant parsed as %+v: %v", grant.Scope, err)
538+ }
539+}
540diff --git a/internal/policy/policy.go b/internal/policy/policy.go
541index 4332396..6b54f0c 100644
542--- a/internal/policy/policy.go
543+++ b/internal/policy/policy.go
544@@ -52,6 +52,9 @@ type Features struct {
545 // not a repository visibility hint: all reads require current membership.
546 Grasp08 bool `json:"grasp08"`
547 Push bool `json:"push"`
548+ // Jobs admits NIP-90 job requests, results and feedback from members
549+ // and agents. It is on unless the owner switches it off.
550+ Jobs bool `json:"jobs"`
551 }
552
553 type Delivery struct {
554@@ -132,11 +135,13 @@ type Policy struct {
555 // DefaultRooms is the room allowance applied when a policy does not name one.
556 const DefaultRooms = 64
557
558-// UnmarshalJSON keeps the room allowance at its default when a stored policy
559-// predates the field, so existing tenants do not lose room creation.
560+// UnmarshalJSON keeps the room allowance and the long task switch at their
561+// defaults when a stored policy predates the fields, so existing tenants do
562+// not lose room creation or job traffic.
563 func (p *Policy) UnmarshalJSON(data []byte) error {
564 type plain Policy
565 decoded := plain{Rooms: DefaultRooms}
566+ decoded.Features.Jobs = true
567 if err := json.Unmarshal(data, &decoded); err != nil {
568 return err
569 }
570@@ -146,7 +151,7 @@ func (p *Policy) UnmarshalJSON(data []byte) error {
571
572 func Defaults(owner string) Policy {
573 return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7, Rooms: DefaultRooms,
574- Features: Features{Search: "prose", Sync: true, Count: true, Discovery: true, Names: true, Files: true, Pages: true, Signer: true, Sites: Sites{Enabled: true, Mirror: true}},
575+ 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},
576 Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}}
577 }
578
579diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go
580index d73b25b..4a201bb 100644
581--- a/internal/policy/policy_test.go
582+++ b/internal/policy/policy_test.go
583@@ -24,6 +24,26 @@ func TestPatchOmittedAndEmptySections(t *testing.T) {
584 }
585 }
586
587+func TestJobsDefaultOnUntilSwitchedOff(t *testing.T) {
588+ if !Defaults("owner").Features.Jobs {
589+ t.Fatal("long tasks are off by default")
590+ }
591+ var stored Policy
592+ if err := json.Unmarshal([]byte(`{"owner":"owner","writes":"open","reads":"open","features":{"search":"prose"}}`), &stored); err != nil {
593+ t.Fatal(err)
594+ }
595+ if !stored.Features.Jobs {
596+ t.Fatal("a stored policy without the field lost long tasks")
597+ }
598+ patched, err := Patch(Defaults("owner"), map[string]json.RawMessage{"features": json.RawMessage(`{"jobs":false}`)})
599+ if err != nil {
600+ t.Fatal(err)
601+ }
602+ if patched.Features.Jobs || patched.Features.Search != "prose" {
603+ t.Fatalf("patch did not switch long tasks off: %#v", patched.Features)
604+ }
605+}
606+
607 func TestPrivateReadRequiresRecipient(t *testing.T) {
608 p := Defaults("owner")
609 p.Reads = "members"

About

ownerc01503e8035f
visibilitypublic
heade5a69f652e83

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs