| 1 | diff --git a/docs/agents.md b/docs/agents.md |
| 2 | index 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 | |
| 32 | diff --git a/docs/mcp.md b/docs/mcp.md |
| 33 | index 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 | |
| 63 | diff --git a/internal/daemon/jobs_browse.go b/internal/daemon/jobs_browse.go |
| 64 | index 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 | }() |
| 83 | diff --git a/internal/daemon/jobs_test.go b/internal/daemon/jobs_test.go |
| 84 | index 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"}}, "") |
| 96 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 97 | index 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 | } |
| 145 | diff --git a/internal/daemon/site_snapshot_jobs_test.go b/internal/daemon/site_snapshot_jobs_test.go |
| 146 | new file mode 100644 |
| 147 | index 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 | +} |
| 201 | diff --git a/internal/event/kinds.go b/internal/event/kinds.go |
| 202 | index 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 } |
| 231 | diff --git a/internal/event/kinds_test.go b/internal/event/kinds_test.go |
| 232 | new file mode 100644 |
| 233 | index 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 | +} |
| 267 | diff --git a/internal/gates/jobs.go b/internal/gates/jobs.go |
| 268 | index 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): |
| 289 | diff --git a/internal/gates/site_snapshot_test.go b/internal/gates/site_snapshot_test.go |
| 290 | new file mode 100644 |
| 291 | index 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 | +} |
| 358 | diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go |
| 359 | index 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()) |
| 396 | diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go |
| 397 | index 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) { |