tiny » repos/tinyrelay/commit

Commit 728249910fc8

browse files at this commit | history

1diff --git a/docs/agents.md b/docs/agents.md
2index 9e997a6..58914db 100644
3--- a/docs/agents.md
4+++ b/docs/agents.md
5@@ -162,7 +162,7 @@ A long task is work that takes longer than one exchange: a transcription, a summ
6
7 ### The flow
8
9-1. A person or an agent publishes a job request, an event of kind 5000 to 5999. The kind names the type of work. The request may carry `i` tags for its inputs, an `output` tag for the expected MIME type, `param` tags, a `bid` in millisats, `relays` where answers should go, `p` tags for the providers it prefers and an `expiration`.
10+1. A person or an agent publishes a job request, an event of kind 5000 to 5127 or 5129 to 5999. The kind names the type of work. The request may carry `i` tags for its inputs, an `output` tag for the expected MIME type, `param` tags, a `bid` in millisats, `relays` where answers should go, `p` tags for the providers it prefers and an `expiration`.
11 2. A serving agent answers with job feedback, kind 7000, as often as it likes. Feedback names the request in an `e` tag and the requester in a `p` tag, and carries a `status` tag of `payment-required`, `processing`, `error`, `success` or `partial`, with optional extra text, an `amount` in millisats with an optional invoice, and a sample of the output in the content.
12 3. When the work is done, the agent publishes the result, an event of the request kind plus 1000. It names the request and the requester the same way, carries the request as JSON in a `request` tag with the request's inputs, and holds the output in its content.
13
14@@ -183,7 +183,7 @@ An agent takes part through its grant. A `k` tag admits one kind, as for any oth
15
16 | Value | Lets the agent publish |
17 | --- | --- |
18-| `request` | Job requests, kinds 5000 to 5999. |
19+| `request` | Job requests, kinds 5000 to 5127 or 5129 to 5999. |
20 | `serve` | Job results, kinds 6000 to 6999, and job feedback, kind 7000, in answer to requests the relay holds. |
21 | `both` | Both. |
22
23@@ -213,7 +213,7 @@ A result, or feedback that reports `error` or `payment-required`, wakes the requ
24
25 ## Static sites
26
27-An agent can publish a static site and upload the files behind it, with the owner deciding how long that work lives and whether the files must be encrypted. The site is a [NIP-5A](https://github.com/nostr-protocol/nips/pull/2004) manifest under the agent's own key: kind 15128 for the key's site, whose label is the agent's `npub`, or kind 35128 for a named site, whose label is the key in base36 followed by the name. The files are blobs the agent uploads to the relay's file store.
28+An agent can publish a static site and upload the files behind it, with the owner deciding how long that work lives and whether the files must be encrypted. The site is a [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md) manifest under the agent's own key: kind 15128 for the key's site, whose label is the agent's `npub`, or kind 35128 for a named site, whose label is the key in base36 followed by the name. The files are blobs the agent uploads to the relay's file store.
29
30 ### The grant
31
32diff --git a/docs/mcp.md b/docs/mcp.md
33index b8a83a4..be8ca6c 100644
34--- a/docs/mcp.md
35+++ b/docs/mcp.md
36@@ -116,7 +116,7 @@ Write tools, which publish through the same path as `POST /events`:
37 | `publish_site` | Publish a static site manifest, kind 15128 for the key's own site or kind 35128 for a named site, from `paths` given as `[path, sha256]` pairs and an optional `expiration`. |
38 | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. |
39 | `request_decision` | Ask a person to approve, decide or answer with a kind 9 room message or a kind 1111 comment carrying a `request` tag. |
40-| `request_job` | Ask for a long task with a NIP-90 job request of kind 5000 to 5999 carrying its inputs, output type, parameters, bid and relays. |
41+| `request_job` | Ask for a long task with a NIP-90 job request of kind 5000 to 5127 or 5129 to 5999 carrying its inputs, output type, parameters, bid and relays. |
42 | `job_feedback` | Report progress on a long task with a kind 7000 event naming the request, the requester and a status. |
43 | `job_result` | Deliver a long task's output with an event of the request kind plus 1000, naming the request and the requester. |
44
45@@ -136,7 +136,7 @@ The person answers with a kind 7 reaction to the published event from the asked
46
47 ### Static sites
48
49-`publish_site` builds a [NIP-5A](https://github.com/nostr-protocol/nips/pull/2004) manifest. Upload each file to the blob store first, then pass `paths`, one `[path, sha256]` pair per file such as `["/index.html", "<sha256>"]`, and optionally `label` and `expiration`. Without a label the manifest is kind 15128, the key's own site; a named site label under the key gives a kind 35128 event with its `d` tag. Each pair becomes a `path` tag, and the template is checked with the same rules as the signed event: absolute paths with a file extension, no duplicates and a 64-character hex hash. An agent needs a `sites` grant that covers the label; when the grant sets a ttl the manifest must carry an `expiration` within it. See [Static sites](agents.md#static-sites).
50+`publish_site` builds a [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md) manifest. Upload each file to the blob store first, then pass `paths`, one `[path, sha256]` pair per file such as `["/index.html", "<sha256>"]`, and optionally `label` and `expiration`. Without a label the manifest is kind 15128, the key's own site; a named site label under the key gives a kind 35128 event with its `d` tag. Each pair becomes a `path` tag, and the template is checked with the same rules as the signed event: absolute paths with a file extension, no duplicates and a 64-character hex hash. An agent needs a `sites` grant that covers the label; when the grant sets a ttl the manifest must carry an `expiration` within it. See [Static sites](agents.md#static-sites).
51
52 ### Room attachments
53
54@@ -152,7 +152,7 @@ Room uploads use `/media/<hash>.<extension>` URLs compatible with Buzz. Reads fo
55
56 ### Long tasks
57
58-`request_job` builds a [NIP-90](https://github.com/nostr-protocol/nips/blob/master/90.md) job request. Pass `kind` (5000 to 5999) and `inputs`, each with `data` and a `type` of `url`, `event`, `job` or `text` plus an optional `relay` and `marker`, and any of `output`, `params`, `bid` in millisats, `relays` and `expiration`. Each input becomes an `i` tag and each parameter a `param` tag.
59+`request_job` builds a [NIP-90](https://github.com/nostr-protocol/nips/blob/master/90.md) job request. Pass `kind` (5000 to 5127 or 5129 to 5999) and `inputs`, each with `data` and a `type` of `url`, `event`, `job` or `text` plus an optional `relay` and `marker`, and any of `output`, `params`, `bid` in millisats, `relays` and `expiration`. Each input becomes an `i` tag and each parameter a `param` tag.
60
61 A serving agent answers with `job_feedback`, which takes `e` (the request id), `p` (the requester) and `status` (`payment-required`, `processing`, `error`, `success` or `partial`) plus optional `info`, `amount`, `invoice` and `content`, and then with `job_result`, which takes the `request` event and `content` plus optional `amount` and `invoice`. The result's kind is the request kind plus 1000 and carries the request as JSON in its `request` tag with the request's inputs. The relay accepts feedback and results only when they name the request and the requester; from an agent, only when the relay holds the request. `list_jobs` and `read_job` follow the work. See [Long tasks](agents.md#long-tasks).
62
63diff --git a/internal/daemon/jobs_browse.go b/internal/daemon/jobs_browse.go
64index 5e4233e..f1e9e75 100644
65--- a/internal/daemon/jobs_browse.go
66+++ b/internal/daemon/jobs_browse.go
67@@ -23,11 +23,13 @@ const jobScan = 1000
68
69 var jobStates = []string{"open", "done", "all"}
70
71-// jobRequestKinds lists every request kind, so one filter covers the range.
72+// jobRequestKinds lists the kinds classified as long-task requests.
73 var jobRequestKinds = func() []int {
74 kinds := make([]int, 0, event.KIND_JOB_REQUEST_MAX-event.KIND_JOB_REQUEST_MIN+1)
75 for kind := event.KIND_JOB_REQUEST_MIN; kind <= event.KIND_JOB_REQUEST_MAX; kind++ {
76- kinds = append(kinds, kind)
77+ if event.IsJobRequest(kind) {
78+ kinds = append(kinds, kind)
79+ }
80 }
81 return kinds
82 }()
83diff --git a/internal/daemon/jobs_test.go b/internal/daemon/jobs_test.go
84index 3b16d5c..acf60ef 100644
85--- a/internal/daemon/jobs_test.go
86+++ b/internal/daemon/jobs_test.go
87@@ -81,7 +81,7 @@ func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {
88 if result, isError = call("request_job", map[string]any{"kind": 1}, testMemberSecret); !isError {
89 t.Fatalf("request_job bad kind: %s", text(result))
90 }
91- if result, isError = call("request_job", map[string]any{"event": mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello"}}, "")}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job or text") || !strings.Contains(text(result), "Expected a signed event of kind 5000 to 5999") {
92+ if result, isError = call("request_job", map[string]any{"event": mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello"}}, "")}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job or text") || !strings.Contains(text(result), "Expected a signed event of kind 5000 to 5127 or 5129 to 5999") {
93 t.Fatalf("request_job malformed signed: %s", text(result))
94 }
95 request := mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")
96diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
97index 021f061..5e99f94 100644
98--- a/internal/daemon/mcp_tools.go
99+++ b/internal/daemon/mcp_tools.go
100@@ -41,7 +41,7 @@ var (
101 mcpRoomID = map[string]any{"type": "string", "pattern": "^[a-z0-9_-]{1,64}$", "description": "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore."}
102 mcpPageName = map[string]any{"type": "string", "minLength": 1, "description": "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped."}
103 mcpUnixTime = map[string]any{"type": "integer", "minimum": 1, "description": "Unix time in seconds."}
104- mcpJobKind = map[string]any{"type": "integer", "minimum": event.KIND_JOB_REQUEST_MIN, "maximum": event.KIND_JOB_REQUEST_MAX, "description": "NIP-90 job request kind, 5000 to 5999."}
105+ mcpJobKind = map[string]any{"type": "integer", "minimum": event.KIND_JOB_REQUEST_MIN, "maximum": event.KIND_JOB_REQUEST_MAX, "not": map[string]any{"enum": []int{event.KIND_SITE_SNAPSHOT}}, "description": "NIP-90 job request kind, 5000 to 5127 or 5129 to 5999."}
106 mcpMsats = map[string]any{"type": "integer", "minimum": 0, "description": "Amount in millisats."}
107 mcpJobStatus = map[string]any{"type": "string", "enum": event.JobFeedbackStatuses}
108 mcpJobInputs = map[string]any{"type": "array", "items": mcp.Object(map[string]any{"data": mcpText, "type": map[string]any{"type": "string", "enum": event.JobInputTypes}, "relay": mcpText, "marker": mcpText}, "data", "type"), "description": "Job inputs, each becoming an i tag."}
109@@ -71,7 +71,7 @@ const (
110 mcpMergeShape = `Expected a signed kind 818 event with tags ["a","30818:<destination>:<page name>"], ["p","<destination>"], ["e","<proposed version id>","","source"] and optional ["e","<base version id>"], with the explanation in content.`
111 mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`
112 mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"], or a signed kind 1111 event with NIP-22 tags ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<root id>","","<root pubkey>"] and ["k","<root kind>"], carrying ["request","approve", "decide" or "question"], ["p","<asked pubkey>"] and optional ["expiration","<unix time>"] and ["subject","<subject>"], with the question in content.`
113- mcpJobRequestShape = `Expected a signed event of kind 5000 to 5999 with optional tags ["i","<data>","url" or "event" or "job" or "text","<relay>","<marker>"], ["output","<mime type>"], ["param","<key>","<value>"], ["bid","<millisats>"], ["relays","wss://..."], ["p","<provider pubkey>"] and ["expiration","<unix time>"], with content empty or the encrypted inputs.`
114+ mcpJobRequestShape = `Expected a signed event of kind 5000 to 5127 or 5129 to 5999 with optional tags ["i","<data>","url" or "event" or "job" or "text","<relay>","<marker>"], ["output","<mime type>"], ["param","<key>","<value>"], ["bid","<millisats>"], ["relays","wss://..."], ["p","<provider pubkey>"] and ["expiration","<unix time>"], with content empty or the encrypted inputs.`
115 mcpJobFeedbackShape = `Expected a signed kind 7000 event with tags ["status","payment-required" or "processing" or "error" or "success" or "partial","<info>"], ["e","<request id>"], ["p","<requester pubkey>"] and optional ["amount","<millisats>","<bolt11>"], with content empty or a partial result.`
116 mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["request","<request event JSON>"], the request's ["i",...] tags and optional ["amount","<millisats>","<bolt11>"], with the output in content.`
117 mcpSiteShape = `Expected a signed kind 15128 event for your own site, or a signed kind 35128 event with ["d","<site name>"] for a named site under your key, with one ["path","/<file path>","<sha256 of the file>"] tag per file, the blobs already uploaded, and an optional ["expiration","<unix time>"] tag.`
118@@ -230,7 +230,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
119 add("publish_site", "Publish a static site manifest (NIP-5A). Upload the files to the blob store first, then pass paths as [path, sha256] pairs, an optional label (your npub for your own site, the default, or a named site label under your key) and an optional expiration, to receive the unsigned kind 15128 or 35128 event, sign it, then call again with the signed event. An agent needs a sites grant that covers the label; a grant with a ttl requires the expiration.", mcp.Object(map[string]any{"event": mcpEvent, "label": map[string]any{"type": "string", "minLength": 1, "description": "Site label: your npub, or a named site label under your key. Defaults to your own site."}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "array", "items": mcpText}, "description": "One [path, sha256] pair per file, such as [\"/index.html\", \"<sha256>\"]."}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildSite, mcpCheckSite, mcpSiteShape))
120 add("create_room", "Create a chat room with a kind 9007 event. Relay members may do this. Pass room (the new id), name and optional about and visibility (open or members) to receive the unsigned event, sign it, then call again with the signed event. The signer becomes the room owner.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "name": mcpText, "about": mcpText, "visibility": map[string]any{"type": "string", "enum": []string{"open", "members"}}}), mcpPublishes, t.mcpWrite(mcpBuildRoom, mcpCheckRoom, mcpRoomShape))
121 add("request_decision", "Ask a person for an approval, a decision or an answer. Pass pubkey (the person asked), request (approve, decide or question) and content, plus room for a kind 9 room message or root, root_kind and root_pubkey for a kind 1111 comment under an issue, pull request or other event, and optional expiration and subject, to receive the unsigned event carrying a request tag, sign it, then call again with the signed event."+mcpAnswerNote, mcp.Object(map[string]any{"event": mcpEvent, "pubkey": mcpPubKey, "request": map[string]any{"type": "string", "enum": mcpRequestKinds}, "content": mcpText, "room": mcpRoomID, "root": mcpHash, "root_kind": map[string]any{"type": "integer", "minimum": 0}, "root_pubkey": mcpPubKey, "expiration": mcpUnixTime, "subject": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildRequest, mcpCheckRequest, mcpRequestShape))
122- add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind (5000 to 5999) and inputs, plus optional output, params, bid in millisats, relays and expiration, to receive the unsigned event, sign it, then call again with the signed event. A serving agent answers with job feedback and a result naming the request; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "inputs": mcpJobInputs, "output": map[string]any{"type": "string", "description": "Expected output MIME type."}, "params": map[string]any{"type": "object", "description": "Job parameters as key and string value, each becoming a param tag."}, "bid": mcpMsats, "relays": map[string]any{"type": "array", "items": mcpText}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape))
123+ add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind (5000 to 5127 or 5129 to 5999) and inputs, plus optional output, params, bid in millisats, relays and expiration, to receive the unsigned event, sign it, then call again with the signed event. A serving agent answers with job feedback and a result naming the request; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "inputs": mcpJobInputs, "output": map[string]any{"type": "string", "description": "Expected output MIME type."}, "params": map[string]any{"type": "object", "description": "Job parameters as key and string value, each becoming a param tag."}, "bid": mcpMsats, "relays": map[string]any{"type": "array", "items": mcpText}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape))
124 add("job_feedback", "Report progress on a long task with a kind 7000 job feedback event. Pass e (the request id), p (the requester) and status (payment-required, processing, error, success or partial), plus optional info, amount in millisats, invoice and content, to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "e": mcpHash, "p": mcpPubKey, "status": mcpJobStatus, "info": mcpText, "amount": mcpMsats, "invoice": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobFeedback, mcpCheckJobFeedback, mcpJobFeedbackShape))
125 add("job_result", "Deliver a long task's output with a NIP-90 job result. Pass request (the job request event) or kind, e and p, plus content and optional amount and invoice, to receive the unsigned event of the request kind plus 1000, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "request": map[string]any{"type": "object", "description": "The job request event being answered."}, "kind": map[string]any{"type": "integer", "minimum": event.KIND_JOB_RESULT_MIN, "maximum": event.KIND_JOB_RESULT_MAX}, "e": mcpHash, "p": mcpPubKey, "content": mcpText, "amount": mcpMsats, "invoice": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobResult, mcpCheckJobResult, mcpJobResultShape))
126 return registry, err
127@@ -914,7 +914,7 @@ func mcpCheckRequest(e event.Event) error {
128 func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) {
129 kind := call.Int("kind")
130 if !event.IsJobRequest(kind) {
131- return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5999")
132+ return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5127 or 5129 to 5999")
133 }
134 tags := [][]string{}
135 if inputs, ok := call.Arguments["inputs"].([]any); ok {
136@@ -1026,7 +1026,7 @@ func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) {
137 err = json.Unmarshal(encoded, &request)
138 }
139 if err != nil || !event.IsJobRequest(request.Kind) || !hex64(request.ID) || !hex64(request.PubKey) {
140- return mcpUnsigned{}, errors.New("request must be a job request event with id, pubkey and a kind of 5000 to 5999")
141+ return mcpUnsigned{}, errors.New("request must be a job request event with id, pubkey and a kind of 5000 to 5127 or 5129 to 5999")
142 }
143 hasRequest = true
144 }
145diff --git a/internal/daemon/site_snapshot_jobs_test.go b/internal/daemon/site_snapshot_jobs_test.go
146new file mode 100644
147index 0000000..1865263
148--- /dev/null
149+++ b/internal/daemon/site_snapshot_jobs_test.go
150@@ -0,0 +1,50 @@
151+package daemon
152+
153+import (
154+ "context"
155+ "strings"
156+ "testing"
157+ "time"
158+
159+ "github.com/FelineStateMachine/tinyrelay/internal/event"
160+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
161+ "github.com/FelineStateMachine/tinyrelay/internal/sites"
162+)
163+
164+func TestSiteSnapshotStaysOutOfJobReads(t *testing.T) {
165+ _, tenant := testTenant(t)
166+ ctx := context.Background()
167+ now := time.Now().Unix()
168+ owner := tenant.Policy().Owner
169+ paths := [][]string{{"path", "/index.html", strings.Repeat("a", 64)}}
170+ tags := append(paths, []string{"x", sites.Aggregate(paths), "aggregate"}, []string{"a", "15128:" + owner + ":"})
171+ snapshot := wikiPublish(t, tenant, wikiOwnerSecret, event.KIND_SITE_SNAPSHOT, now, "", tags...)
172+ job := wikiPublish(t, tenant, wikiOwnerSecret, 5001, now-1, "", []string{"i", "hello", "text"})
173+
174+ result, err := tenant.browseJobs(ctx, owner, jobBrowseRequest{Limit: 1})
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ items := result.(map[string]any)["items"].([]jobItem)
179+ if len(items) != 1 || items[0].ID != job.ID {
180+ t.Fatalf("job listing = %+v; want request %s", items, job.ID)
181+ }
182+ if _, err := tenant.browseJob(ctx, owner, snapshot.ID); err == nil || !strings.Contains(err.Error(), "not found: job request") {
183+ t.Fatalf("snapshot job read error = %v", err)
184+ }
185+ if _, err := tenant.browseJob(ctx, owner, job.ID); err != nil {
186+ t.Fatalf("job read: %v", err)
187+ }
188+}
189+
190+func TestSiteSnapshotCannotBuildJobRequest(t *testing.T) {
191+ if err := mcp.Validate(mcpJobKind, float64(event.KIND_SITE_SNAPSHOT)); err == nil {
192+ t.Fatal("site snapshot kind accepted by the advertised job schema")
193+ }
194+ if _, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{"kind": float64(event.KIND_SITE_SNAPSHOT)}}); err == nil {
195+ t.Fatal("site snapshot kind accepted by the job request builder")
196+ }
197+ if err := mcpCheckJobRequest(event.Event{Kind: event.KIND_SITE_SNAPSHOT}); err == nil {
198+ t.Fatal("site snapshot accepted by the signed job request checker")
199+ }
200+}
201diff --git a/internal/event/kinds.go b/internal/event/kinds.go
202index da02910..819322a 100644
203--- a/internal/event/kinds.go
204+++ b/internal/event/kinds.go
205@@ -64,8 +64,9 @@ const (
206 KIND_AGENT_GRANT = 30392
207 )
208
209-// NIP-90 long tasks: a job request kind in 5000 to 5999 is answered by a
210-// result kind 1000 higher and by feedback of kind 7000.
211+// NIP-90 long tasks use request kinds 5000 to 5127 and 5129 to 5999.
212+// Each request is answered by a result kind 1000 higher and by feedback
213+// of kind 7000. Kind 5128 belongs to NIP-5A site snapshots.
214 const (
215 KIND_JOB_REQUEST_MIN = 5000
216 KIND_JOB_REQUEST_MAX = 5999
217@@ -80,8 +81,11 @@ var JobFeedbackStatuses = []string{"payment-required", "processing", "error", "s
218 // JobInputTypes is the NIP-90 vocabulary of the i tag's input type.
219 var JobInputTypes = []string{"url", "event", "job", "text"}
220
221-// IsJobRequest reports whether kind is a NIP-90 request kind.
222-func IsJobRequest(kind int) bool { return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX }
223+// IsJobRequest reports whether kind belongs to long-task requests.
224+// NIP-5A site snapshots have their own admission and manifest rules.
225+func IsJobRequest(kind int) bool {
226+ return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX && kind != KIND_SITE_SNAPSHOT
227+}
228
229 // IsJobResult reports whether kind is a NIP-90 result kind.
230 func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX }
231diff --git a/internal/event/kinds_test.go b/internal/event/kinds_test.go
232new file mode 100644
233index 0000000..b483958
234--- /dev/null
235+++ b/internal/event/kinds_test.go
236@@ -0,0 +1,30 @@
237+package event
238+
239+import "testing"
240+
241+func TestJobKindClassification(t *testing.T) {
242+ tests := []struct {
243+ kind int
244+ request bool
245+ result bool
246+ job bool
247+ answer int
248+ }{
249+ {kind: 4999},
250+ {kind: 5000, request: true, job: true, answer: 6000},
251+ {kind: 5127, request: true, job: true, answer: 6127},
252+ {kind: KIND_SITE_SNAPSHOT},
253+ {kind: 5129, request: true, job: true, answer: 6129},
254+ {kind: 5999, request: true, job: true, answer: 6999},
255+ {kind: 6000, result: true, job: true},
256+ {kind: 6128, result: true, job: true},
257+ {kind: 6999, result: true, job: true},
258+ {kind: KIND_JOB_FEEDBACK, job: true},
259+ {kind: 7001},
260+ }
261+ for _, tt := range tests {
262+ if IsJobRequest(tt.kind) != tt.request || IsJobResult(tt.kind) != tt.result || IsJobKind(tt.kind) != tt.job || JobResultKind(tt.kind) != tt.answer {
263+ t.Errorf("kind %d: request=%v result=%v job=%v answer=%d; want request=%v result=%v job=%v answer=%d", tt.kind, IsJobRequest(tt.kind), IsJobResult(tt.kind), IsJobKind(tt.kind), JobResultKind(tt.kind), tt.request, tt.result, tt.job, tt.answer)
264+ }
265+ }
266+}
267diff --git a/internal/gates/jobs.go b/internal/gates/jobs.go
268index c6a85de..690833a 100644
269--- a/internal/gates/jobs.go
270+++ b/internal/gates/jobs.go
271@@ -1,7 +1,7 @@
272 package gates
273
274 // Long tasks follow NIP-90: a member or agent publishes a job request
275-// (kind 5000 to 5999), and a serving agent or member answers it with
276+// (kind 5000 to 5127 or 5129 to 5999), and a serving agent or member answers it with
277 // feedback (kind 7000) and a result (the request kind plus 1000). The relay
278 // keeps every event signed by its author; it only checks that requests,
279 // feedback and results carry the tags the protocol needs, that they are
280@@ -21,7 +21,7 @@ import (
281 )
282
283 // JobShape checks the tags of a job request, result or feedback event. It
284-// returns nil for kinds outside the job ranges.
285+// returns nil for other event kinds.
286 func JobShape(e event.Event) error {
287 switch {
288 case event.IsJobRequest(e.Kind):
289diff --git a/internal/gates/site_snapshot_test.go b/internal/gates/site_snapshot_test.go
290new file mode 100644
291index 0000000..c168c5d
292--- /dev/null
293+++ b/internal/gates/site_snapshot_test.go
294@@ -0,0 +1,63 @@
295+package gates
296+
297+import (
298+ "context"
299+ "strconv"
300+ "strings"
301+ "testing"
302+
303+ "github.com/FelineStateMachine/tinyrelay/internal/event"
304+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
305+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
306+ "github.com/FelineStateMachine/tinyrelay/internal/sites"
307+)
308+
309+func validSiteSnapshot(t *testing.T, secret, source string, createdAt int64) event.Event {
310+ t.Helper()
311+ hash := strings.Repeat("a", 64)
312+ paths := [][]string{{"path", "/index.html", hash}}
313+ e := event.Event{
314+ Kind: sites.KindSiteSnapshot,
315+ CreatedAt: createdAt,
316+ Tags: append(paths,
317+ []string{"x", sites.Aggregate(paths), "aggregate"},
318+ []string{"a", "15128:" + source + ":"},
319+ ),
320+ }
321+ if err := event.Sign(&e, secret); err != nil {
322+ t.Fatal(err)
323+ }
324+ if err := sites.ValidateManifest(e); err != nil {
325+ t.Fatalf("snapshot fixture is invalid: %v", err)
326+ }
327+ return e
328+}
329+
330+func TestSiteSnapshotIsNotControlledByJobFeature(t *testing.T) {
331+ f := newAgentFixture(t)
332+ ctx := context.Background()
333+ snapshot := validSiteSnapshot(t, agentOtherSecret, f.owner, agentNow)
334+ session := relay.Session{PubKeys: []string{snapshot.PubKey}}
335+
336+ for _, jobsEnabled := range []bool{true, false} {
337+ t.Run("jobs-enabled="+strconv.FormatBool(jobsEnabled), func(t *testing.T) {
338+ p := policy.Defaults(f.owner)
339+ p.Features.Jobs = jobsEnabled
340+ f.gate.cfg.Policy = func() policy.Policy { return p }
341+ if err := f.gate.Write(ctx, snapshot, session, agentNow); err != nil {
342+ t.Fatalf("valid site snapshot rejected with jobs=%t: %v", jobsEnabled, err)
343+ }
344+ })
345+ }
346+}
347+
348+func TestSiteSnapshotRespectsRelayWritePolicy(t *testing.T) {
349+ f := newAgentFixture(t)
350+ p := policy.Defaults(f.owner)
351+ p.Writes = "allowlist"
352+ p.Features.Jobs = false
353+ f.gate.cfg.Policy = func() policy.Policy { return p }
354+ snapshot := validSiteSnapshot(t, agentOtherSecret, f.owner, agentNow)
355+ err := f.gate.Write(context.Background(), snapshot, relay.Session{PubKeys: []string{snapshot.PubKey}}, agentNow)
356+ expectError(t, err, "blocked:", "relay write policy")
357+}
358diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go
359index f8d9073..d4d5e43 100644
360--- a/internal/mcp/mcp_test.go
361+++ b/internal/mcp/mcp_test.go
362@@ -189,6 +189,33 @@ func TestBodyValidation(t *testing.T) {
363 }
364 }
365
366+func TestValidateNotSchema(t *testing.T) {
367+ schema := map[string]any{
368+ "type": "integer",
369+ "minimum": 5000,
370+ "maximum": 5999,
371+ "not": map[string]any{"enum": []int{5128}},
372+ }
373+ for _, tc := range []struct {
374+ name string
375+ value float64
376+ valid bool
377+ }{
378+ {name: "below range", value: 4999, valid: false},
379+ {name: "before excluded value", value: 5127, valid: true},
380+ {name: "excluded value", value: 5128, valid: false},
381+ {name: "after excluded value", value: 5129, valid: true},
382+ {name: "above range", value: 6000, valid: false},
383+ } {
384+ t.Run(tc.name, func(t *testing.T) {
385+ err := Validate(schema, tc.value)
386+ if (err == nil) != tc.valid {
387+ t.Fatalf("Validate(%v) error = %v, valid = %v", tc.value, err, tc.valid)
388+ }
389+ })
390+ }
391+}
392+
393 func TestRoutingAndResults(t *testing.T) {
394 s := testServer(t)
395 w, response, obs := serve(t, s, call{method: "server/discover"}.request())
396diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
397index 114456d..36963cc 100644
398--- a/internal/mcp/tools.go
399+++ b/internal/mcp/tools.go
400@@ -153,7 +153,7 @@ func Object(properties map[string]any, required ...string) map[string]any {
401 }
402
403 // Validate checks a value against the subset of JSON Schema 2020-12 the tool
404-// tables use: type, properties, required, additionalProperties, enum,
405+// tables use: type, properties, required, additionalProperties, enum, not,
406 // pattern, minimum, maximum, minLength, maxLength, minProperties and items.
407 func Validate(schema map[string]any, value any) error {
408 return validate(schema, value, "")
409@@ -174,6 +174,11 @@ func validate(schema map[string]any, value any, path string) error {
410 if options := enumOptions(schema["enum"]); len(options) > 0 && !contains(options, scalar(value)) {
411 return fmt.Errorf("%s must be one of %s", where(), strings.Join(options, ", "))
412 }
413+ if excluded, ok := schema["not"].(map[string]any); ok {
414+ if validate(excluded, value, path) == nil {
415+ return fmt.Errorf("%s matches an excluded value", where())
416+ }
417+ }
418 if pattern, ok := schema["pattern"].(string); ok {
419 text, _ := value.(string)
420 if re, err := regexp.Compile(pattern); err == nil && !re.MatchString(text) {

About

ownerc01503e8035f
visibilitypublic
head728249910fc8

Maintainers

  • c01503e8035f owner

Clone

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

Refs