Apply site admission rules to kind 5128 and keep snapshots out of job reads, request builders and MCP input schemas. Preserve relay write policy and agent grant checks. Document the supported request kinds and current NIP-5A source. Verify classification boundaries, permissions, job reads, MCP schema exclusions and end-to-end site snapshot conformance.
- commit
- 728249910fc8cccbc04ace016fd68ef9898076ad
- parent
- ba72f13dcda6d7ae3c8a61124f12e5e1648cda31
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 6 | +- |
| M | docs/ | 6 | +- |
| M | internal/ | 6 | ++- |
| M | internal/ | 2 | +- |
| M | internal/ | 10 | ++-- |
| A | internal/ | 50 | ++++++++++++++++++++++++ |
| M | internal/ | 12 | ++++-- |
| A | internal/ | 30 | ++++++++++++++ |
| M | internal/ | 4 | +- |
| A | internal/ | 63 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 27 | +++++++++++++ |
| M | internal/ | 7 | +++- |
@@ -162,7 +162,7 @@ A long task is work that takes longer than one exchange: a transcription, a summ
162162 163163 ### The flow164164 165-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`.165+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`.166166 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.167167 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.168168 @@ -183,7 +183,7 @@ An agent takes part through its grant. A `k` tag admits one kind, as for any oth
183183 184184 | Value | Lets the agent publish |185185 | --- | --- |186-| `request` | Job requests, kinds 5000 to 5999. |186+| `request` | Job requests, kinds 5000 to 5127 or 5129 to 5999. |187187 | `serve` | Job results, kinds 6000 to 6999, and job feedback, kind 7000, in answer to requests the relay holds. |188188 | `both` | Both. |189189 @@ -213,7 +213,7 @@ A result, or feedback that reports `error` or `payment-required`, wakes the requ
213213 214214 ## Static sites215215 216-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.216+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.217217 218218 ### The grant219219 @@ -116,7 +116,7 @@ Write tools, which publish through the same path as `POST /events`:
116116 | `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`. |117117 | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. |118118 | `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. |119-| `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. |119+| `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. |120120 | `job_feedback` | Report progress on a long task with a kind 7000 event naming the request, the requester and a status. |121121 | `job_result` | Deliver a long task's output with an event of the request kind plus 1000, naming the request and the requester. |122122 @@ -136,7 +136,7 @@ The person answers with a kind 7 reaction to the published event from the asked
136136 137137 ### Static sites138138 139-`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).139+`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).140140 141141 ### Room attachments142142 @@ -152,7 +152,7 @@ Room uploads use `/media/<hash>.<extension>` URLs compatible with Buzz. Reads fo
152152 153153 ### Long tasks154154 155-`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.155+`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.156156 157157 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).158158 @@ -23,11 +23,13 @@ const jobScan = 1000
2323 2424 var jobStates = []string{"open", "done", "all"}2525 26-// jobRequestKinds lists every request kind, so one filter covers the range.26+// jobRequestKinds lists the kinds classified as long-task requests.2727 var jobRequestKinds = func() []int {2828 kinds := make([]int, 0, event.KIND_JOB_REQUEST_MAX-event.KIND_JOB_REQUEST_MIN+1)2929 for kind := event.KIND_JOB_REQUEST_MIN; kind <= event.KIND_JOB_REQUEST_MAX; kind++ {30- kinds = append(kinds, kind)30+ if event.IsJobRequest(kind) {31+ kinds = append(kinds, kind)32+ }3133 }3234 return kinds3335 }()@@ -81,7 +81,7 @@ func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {
8181 if result, isError = call("request_job", map[string]any{"kind": 1}, testMemberSecret); !isError {8282 t.Fatalf("request_job bad kind: %s", text(result))8383 }84- 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") {84+ 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") {8585 t.Fatalf("request_job malformed signed: %s", text(result))8686 }8787 request := mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")@@ -41,7 +41,7 @@ var (
4141 mcpRoomID = map[string]any{"type": "string", "pattern": "^[a-z0-9_-]{1,64}$", "description": "Room id: 1 to 64 lowercase letters, digits, hyphen or underscore."}4242 mcpPageName = map[string]any{"type": "string", "minLength": 1, "description": "Wiki page name or title. Names are normalized: lowercase, spaces to hyphens, punctuation dropped."}4343 mcpUnixTime = map[string]any{"type": "integer", "minimum": 1, "description": "Unix time in seconds."}44- 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."}44+ 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."}4545 mcpMsats = map[string]any{"type": "integer", "minimum": 0, "description": "Amount in millisats."}4646 mcpJobStatus = map[string]any{"type": "string", "enum": event.JobFeedbackStatuses}4747 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."}@@ -71,7 +71,7 @@ const (
7171 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.`7272 mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`7373 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.`74- 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.`74+ 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.`7575 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.`7676 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.`7777 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.`@@ -230,7 +230,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
230230 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))231231 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))232232 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))233- 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))233+ 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))234234 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))235235 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))236236 return registry, err@@ -914,7 +914,7 @@ func mcpCheckRequest(e event.Event) error {
914914 func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) {915915 kind := call.Int("kind")916916 if !event.IsJobRequest(kind) {917- return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5999")917+ return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5127 or 5129 to 5999")918918 }919919 tags := [][]string{}920920 if inputs, ok := call.Arguments["inputs"].([]any); ok {@@ -1026,7 +1026,7 @@ func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) {
10261026 err = json.Unmarshal(encoded, &request)10271027 }10281028 if err != nil || !event.IsJobRequest(request.Kind) || !hex64(request.ID) || !hex64(request.PubKey) {1029- return mcpUnsigned{}, errors.New("request must be a job request event with id, pubkey and a kind of 5000 to 5999")1029+ 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")10301030 }10311031 hasRequest = true10321032 }@@ -0,0 +1,50 @@
1+package daemon2+3+import (4+ "context"5+ "strings"6+ "testing"7+ "time"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"11+ "github.com/FelineStateMachine/tinyrelay/internal/sites"12+)13+14+func TestSiteSnapshotStaysOutOfJobReads(t *testing.T) {15+ _, tenant := testTenant(t)16+ ctx := context.Background()17+ now := time.Now().Unix()18+ owner := tenant.Policy().Owner19+ paths := [][]string{{"path", "/index.html", strings.Repeat("a", 64)}}20+ tags := append(paths, []string{"x", sites.Aggregate(paths), "aggregate"}, []string{"a", "15128:" + owner + ":"})21+ snapshot := wikiPublish(t, tenant, wikiOwnerSecret, event.KIND_SITE_SNAPSHOT, now, "", tags...)22+ job := wikiPublish(t, tenant, wikiOwnerSecret, 5001, now-1, "", []string{"i", "hello", "text"})23+24+ result, err := tenant.browseJobs(ctx, owner, jobBrowseRequest{Limit: 1})25+ if err != nil {26+ t.Fatal(err)27+ }28+ items := result.(map[string]any)["items"].([]jobItem)29+ if len(items) != 1 || items[0].ID != job.ID {30+ t.Fatalf("job listing = %+v; want request %s", items, job.ID)31+ }32+ if _, err := tenant.browseJob(ctx, owner, snapshot.ID); err == nil || !strings.Contains(err.Error(), "not found: job request") {33+ t.Fatalf("snapshot job read error = %v", err)34+ }35+ if _, err := tenant.browseJob(ctx, owner, job.ID); err != nil {36+ t.Fatalf("job read: %v", err)37+ }38+}39+40+func TestSiteSnapshotCannotBuildJobRequest(t *testing.T) {41+ if err := mcp.Validate(mcpJobKind, float64(event.KIND_SITE_SNAPSHOT)); err == nil {42+ t.Fatal("site snapshot kind accepted by the advertised job schema")43+ }44+ if _, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{"kind": float64(event.KIND_SITE_SNAPSHOT)}}); err == nil {45+ t.Fatal("site snapshot kind accepted by the job request builder")46+ }47+ if err := mcpCheckJobRequest(event.Event{Kind: event.KIND_SITE_SNAPSHOT}); err == nil {48+ t.Fatal("site snapshot accepted by the signed job request checker")49+ }50+}@@ -64,8 +64,9 @@ const (
6464 KIND_AGENT_GRANT = 303926565 )6666 67-// NIP-90 long tasks: a job request kind in 5000 to 5999 is answered by a68-// result kind 1000 higher and by feedback of kind 7000.67+// NIP-90 long tasks use request kinds 5000 to 5127 and 5129 to 5999.68+// Each request is answered by a result kind 1000 higher and by feedback69+// of kind 7000. Kind 5128 belongs to NIP-5A site snapshots.6970 const (7071 KIND_JOB_REQUEST_MIN = 50007172 KIND_JOB_REQUEST_MAX = 5999@@ -80,8 +81,11 @@ var JobFeedbackStatuses = []string{"payment-required", "processing", "error", "s
8081 // JobInputTypes is the NIP-90 vocabulary of the i tag's input type.8182 var JobInputTypes = []string{"url", "event", "job", "text"}8283 83-// IsJobRequest reports whether kind is a NIP-90 request kind.84-func IsJobRequest(kind int) bool { return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX }84+// IsJobRequest reports whether kind belongs to long-task requests.85+// NIP-5A site snapshots have their own admission and manifest rules.86+func IsJobRequest(kind int) bool {87+ return kind >= KIND_JOB_REQUEST_MIN && kind <= KIND_JOB_REQUEST_MAX && kind != KIND_SITE_SNAPSHOT88+}8589 8690 // IsJobResult reports whether kind is a NIP-90 result kind.8791 func IsJobResult(kind int) bool { return kind >= KIND_JOB_RESULT_MIN && kind <= KIND_JOB_RESULT_MAX }@@ -0,0 +1,30 @@
1+package event2+3+import "testing"4+5+func TestJobKindClassification(t *testing.T) {6+ tests := []struct {7+ kind int8+ request bool9+ result bool10+ job bool11+ answer int12+ }{13+ {kind: 4999},14+ {kind: 5000, request: true, job: true, answer: 6000},15+ {kind: 5127, request: true, job: true, answer: 6127},16+ {kind: KIND_SITE_SNAPSHOT},17+ {kind: 5129, request: true, job: true, answer: 6129},18+ {kind: 5999, request: true, job: true, answer: 6999},19+ {kind: 6000, result: true, job: true},20+ {kind: 6128, result: true, job: true},21+ {kind: 6999, result: true, job: true},22+ {kind: KIND_JOB_FEEDBACK, job: true},23+ {kind: 7001},24+ }25+ for _, tt := range tests {26+ if IsJobRequest(tt.kind) != tt.request || IsJobResult(tt.kind) != tt.result || IsJobKind(tt.kind) != tt.job || JobResultKind(tt.kind) != tt.answer {27+ 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)28+ }29+ }30+}@@ -1,7 +1,7 @@
11 package gates22 33 // 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 with4+// (kind 5000 to 5127 or 5129 to 5999), and a serving agent or member answers it with55 // feedback (kind 7000) and a result (the request kind plus 1000). The relay66 // keeps every event signed by its author; it only checks that requests,77 // feedback and results carry the tags the protocol needs, that they are@@ -21,7 +21,7 @@ import (
2121 )2222 2323 // JobShape checks the tags of a job request, result or feedback event. It24-// returns nil for kinds outside the job ranges.24+// returns nil for other event kinds.2525 func JobShape(e event.Event) error {2626 switch {2727 case event.IsJobRequest(e.Kind):@@ -0,0 +1,63 @@
1+package gates2+3+import (4+ "context"5+ "strconv"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/policy"11+ "github.com/FelineStateMachine/tinyrelay/internal/relay"12+ "github.com/FelineStateMachine/tinyrelay/internal/sites"13+)14+15+func validSiteSnapshot(t *testing.T, secret, source string, createdAt int64) event.Event {16+ t.Helper()17+ hash := strings.Repeat("a", 64)18+ paths := [][]string{{"path", "/index.html", hash}}19+ e := event.Event{20+ Kind: sites.KindSiteSnapshot,21+ CreatedAt: createdAt,22+ Tags: append(paths,23+ []string{"x", sites.Aggregate(paths), "aggregate"},24+ []string{"a", "15128:" + source + ":"},25+ ),26+ }27+ if err := event.Sign(&e, secret); err != nil {28+ t.Fatal(err)29+ }30+ if err := sites.ValidateManifest(e); err != nil {31+ t.Fatalf("snapshot fixture is invalid: %v", err)32+ }33+ return e34+}35+36+func TestSiteSnapshotIsNotControlledByJobFeature(t *testing.T) {37+ f := newAgentFixture(t)38+ ctx := context.Background()39+ snapshot := validSiteSnapshot(t, agentOtherSecret, f.owner, agentNow)40+ session := relay.Session{PubKeys: []string{snapshot.PubKey}}41+42+ for _, jobsEnabled := range []bool{true, false} {43+ t.Run("jobs-enabled="+strconv.FormatBool(jobsEnabled), func(t *testing.T) {44+ p := policy.Defaults(f.owner)45+ p.Features.Jobs = jobsEnabled46+ f.gate.cfg.Policy = func() policy.Policy { return p }47+ if err := f.gate.Write(ctx, snapshot, session, agentNow); err != nil {48+ t.Fatalf("valid site snapshot rejected with jobs=%t: %v", jobsEnabled, err)49+ }50+ })51+ }52+}53+54+func TestSiteSnapshotRespectsRelayWritePolicy(t *testing.T) {55+ f := newAgentFixture(t)56+ p := policy.Defaults(f.owner)57+ p.Writes = "allowlist"58+ p.Features.Jobs = false59+ f.gate.cfg.Policy = func() policy.Policy { return p }60+ snapshot := validSiteSnapshot(t, agentOtherSecret, f.owner, agentNow)61+ err := f.gate.Write(context.Background(), snapshot, relay.Session{PubKeys: []string{snapshot.PubKey}}, agentNow)62+ expectError(t, err, "blocked:", "relay write policy")63+}@@ -189,6 +189,33 @@ func TestBodyValidation(t *testing.T) {
189189 }190190 }191191 192+func TestValidateNotSchema(t *testing.T) {193+ schema := map[string]any{194+ "type": "integer",195+ "minimum": 5000,196+ "maximum": 5999,197+ "not": map[string]any{"enum": []int{5128}},198+ }199+ for _, tc := range []struct {200+ name string201+ value float64202+ valid bool203+ }{204+ {name: "below range", value: 4999, valid: false},205+ {name: "before excluded value", value: 5127, valid: true},206+ {name: "excluded value", value: 5128, valid: false},207+ {name: "after excluded value", value: 5129, valid: true},208+ {name: "above range", value: 6000, valid: false},209+ } {210+ t.Run(tc.name, func(t *testing.T) {211+ err := Validate(schema, tc.value)212+ if (err == nil) != tc.valid {213+ t.Fatalf("Validate(%v) error = %v, valid = %v", tc.value, err, tc.valid)214+ }215+ })216+ }217+}218+192219 func TestRoutingAndResults(t *testing.T) {193220 s := testServer(t)194221 w, response, obs := serve(t, s, call{method: "server/discover"}.request())@@ -153,7 +153,7 @@ func Object(properties map[string]any, required ...string) map[string]any {
153153 }154154 155155 // Validate checks a value against the subset of JSON Schema 2020-12 the tool156-// tables use: type, properties, required, additionalProperties, enum,156+// tables use: type, properties, required, additionalProperties, enum, not,157157 // pattern, minimum, maximum, minLength, maxLength, minProperties and items.158158 func Validate(schema map[string]any, value any) error {159159 return validate(schema, value, "")@@ -174,6 +174,11 @@ func validate(schema map[string]any, value any, path string) error {
174174 if options := enumOptions(schema["enum"]); len(options) > 0 && !contains(options, scalar(value)) {175175 return fmt.Errorf("%s must be one of %s", where(), strings.Join(options, ", "))176176 }177+ if excluded, ok := schema["not"].(map[string]any); ok {178+ if validate(excluded, value, path) == nil {179+ return fmt.Errorf("%s matches an excluded value", where())180+ }181+ }177182 if pattern, ok := schema["pattern"].(string); ok {178183 text, _ := value.(string)179184 if re, err := regexp.Compile(pattern); err == nil && !re.MatchString(text) {