clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat: add agent task workflows and reviewed grant requests

Dami committed 7d47562
Support NIP-10 chat threads, stable presence, task cards and inline approvals with Fixi refreshes. Add an optional ACP or command agent runner with bounded queues and cancellation.

Move relay lists and presence sharing to Account, identify agents by their operator, and add operator-reviewed grant requests with notification links, signed replacements and stale-decision protection.

Validate Go and JavaScript suites, live grant and chat flows on mobile, and local relay-to-ACP integration.
commit
7d475627c98871df2ec1345e4c91c911b6f4702e
parent
b5738be3195ee9eff8da3bf2ed658d81079a6baa
author
Dami <dami.etoile@gmail.com>
80 files changed, +4951 -137
Acmd/tiny/agent.go68+++++
Mcmd/tiny/main.go4+-
Mdocs/agents.md33+++
Mdocs/fixi.md2+-
Mdocs/mcp.md5+
Mdocs/rooms.md4+
Ainternal/agentrunner/acp_test.go89+++++++
Ainternal/agentrunner/process.go325++++++++++++++++++++++++++
Ainternal/agentrunner/process_unix.go21++
Ainternal/agentrunner/process_windows.go13+
Ainternal/agentrunner/relay.go226++++++++++++++++++
Ainternal/agentrunner/runner.go375++++++++++++++++++++++++++++++
Ainternal/agentrunner/runner_test.go196++++++++++++++++
Ainternal/agentrunner/service.go234+++++++++++++++++++
Ainternal/community/agent_grant_requests.go350++++++++++++++++++++++++++++
Ainternal/community/agent_grant_requests_test.go34+++
Minternal/community/agents.go5+
Minternal/daemon/approvals.go54+++-
Minternal/daemon/approvals_browse.go1+
Ainternal/daemon/chat_activity.go123++++++++++
Ainternal/daemon/chat_activity_test.go88+++++++
Ainternal/daemon/chat_preferences.go74++++++
Ainternal/daemon/chat_preferences_test.go59+++++
Minternal/daemon/client_browse.go5+-
Ainternal/daemon/grant_admission_test.go194++++++++++++++++
Ainternal/daemon/grant_approvals.go94++++++++
Ainternal/daemon/grant_approvals_test.go157+++++++++++++
Minternal/daemon/http.go4+
Minternal/daemon/jobs_test.go38+++
Ainternal/daemon/mcp_grant_request.go103++++++++
Ainternal/daemon/mcp_grant_request_test.go74++++++
Minternal/daemon/mcp_http.go4+-
Minternal/daemon/mcp_tools.go42+++-
Ainternal/daemon/room_threads.go93+++++++
Minternal/daemon/rooms_stream.go9+-
Minternal/daemon/rooms_test.go66+++++
Minternal/daemon/services.go4+
Minternal/daemon/session_test.go41+++
Minternal/daemon/webui_rooms.go30++-
Ainternal/event/room_reply.go48++++
Ainternal/event/room_reply_test.go30++
Minternal/gates/gates.go28++-
Minternal/gates/jobs.go3+
Minternal/mcp/tools.go5+-
Ainternal/mcp/unicode_schema_test.go16+
Minternal/storage/maintenance.go6+-
Minternal/storage/save.go29++
Minternal/storage/schema.go1+
Minternal/storage/store_test.go25++
Minternal/webui/approvals.go92+++++++
Minternal/webui/approvals_test.go14+
Minternal/webui/browse.html5+-
Ainternal/webui/chat-activity.go184+++++++++++++++
Ainternal/webui/chat-activity.html8+
Ainternal/webui/chat-activity.js83+++++++
Ainternal/webui/chat-presence.js159+++++++++++++
Ainternal/webui/chat_activity_http_test.go83+++++++
Ainternal/webui/chat_activity_test.go58+++++
Minternal/webui/chat_pages.go4+
Minternal/webui/components.js4+-
Ainternal/webui/grant-request.js64+++++
Minternal/webui/page.html14+-
Minternal/webui/private.go2+-
Minternal/webui/public.html31+-
Minternal/webui/read_contracts.go9+-
Minternal/webui/rooms.go42++-
Minternal/webui/rooms.html12+-
Minternal/webui/rooms.js54++++-
Minternal/webui/rooms_test.go7+-
Minternal/webui/shell_test.go19+-
Minternal/webui/style.css34+++-
Minternal/webui/templates.go13+-
Minternal/webui/webui.go66+++--
Ascripts/chat-activity.test.mjs92+++++++
Ascripts/chat-presence.test.mjs146++++++++++++
Ascripts/grant-request.test.mjs36+++
Ascripts/qa/buzz-chat-fixture.mjs33+++
Ascripts/qa/buzz-runner-acp.mjs57+++++
Ascripts/qa/buzz-runner-integration.mjs127++++++++++
Mscripts/rooms.test.mjs4+-
A cmd/tiny/agent.go +68 -0
@@ -0,0 +1,68 @@
1+package main2+3+import (4+ "context"5+ "errors"6+ "flag"7+ "fmt"8+ "io"9+ "os"10+ "path/filepath"11+ "regexp"12+ "strings"13+ "time"14+15+ "github.com/FelineStateMachine/tinyrelay/internal/agentrunner"16+)17+18+func agent(ctx context.Context, args []string, out io.Writer) error {19+ fs := flag.NewFlagSet("tiny agent", flag.ContinueOnError)20+ fs.SetOutput(out)21+ relay := fs.String("relay", "", "Nostr relay WebSocket URL")22+ command := fs.String("command", "", "agent executable")23+ protocol := fs.String("protocol", "acp", "agent protocol: acp or command (text stdin/stdout)")24+ keyEnv := fs.String("key-env", "TINY_AGENT_KEY", "environment variable containing the agent hex key or nsec")25+ state := fs.String("state", "", "queue journal path (required)")26+ cwd := fs.String("cwd", ".", "agent working directory")27+ timeout := fs.Duration("timeout", 30*time.Minute, "maximum task and approval wait")28+ var rooms, commandArgs []string29+ fs.Func("room", "authorized room id (repeatable)", func(value string) error {30+ if !regexp.MustCompile(`^[a-z0-9_-]{1,64}$`).MatchString(value) {31+ return errors.New("invalid room id")32+ }33+ rooms = append(rooms, value)34+ return nil35+ })36+ fs.Func("arg", "argument passed directly to the agent (repeatable)", func(value string) error { commandArgs = append(commandArgs, value); return nil })37+ if err := fs.Parse(args); err != nil {38+ return err39+ }40+ if *relay == "" || *command == "" || *state == "" || len(rooms) == 0 {41+ return errors.New("agent requires --relay, --room, --command and --state")42+ }43+ if fs.NArg() != 0 || (*protocol != "acp" && *protocol != "command") || *timeout <= 0 {44+ return errors.New("invalid agent options")45+ }46+ secret, err := secretKey(os.Getenv(*keyEnv))47+ if err != nil {48+ return fmt.Errorf("agent key: %w", err)49+ }50+ directory, err := filepath.Abs(*cwd)51+ if err != nil {52+ return err53+ }54+ info, err := os.Stat(directory)55+ if err != nil || !info.IsDir() {56+ return errors.New("agent working directory does not exist")57+ }58+ // The subprocess does not need the relay signing key. It receives messages59+ // through stdio and returns requests and output to this signing boundary.60+ environment := []string{}61+ for _, entry := range os.Environ() {62+ if !strings.HasPrefix(entry, *keyEnv+"=") {63+ environment = append(environment, entry)64+ }65+ }66+ log := func(err error) { fmt.Fprintln(out, err) }67+ return agentrunner.Serve(ctx, agentrunner.ServiceOptions{RelayURL: *relay, Secret: secret, Rooms: rooms, StatePath: *state, Timeout: *timeout, Log: log, Process: agentrunner.ProcessOptions{Command: *command, Args: commandArgs, Env: environment, CWD: directory, Protocol: *protocol}})68+}
M cmd/tiny/main.go +3 -1
M docs/agents.md +33 -0
@@ -67,6 +67,12 @@ A `maintain` grant makes the agent a maintainer of that repository, the same as
6767 6868 Rejected events return a `restricted: agent grant ...` reason to the client. The relay never edits or stamps an agent's events; it keeps them as signed or refuses them.6969 70+### Requesting additional access71+72+An active agent can ask its grant operator for additional access with the `request_grant` MCP tool. The agent supplies a reason of 500 characters or fewer and additive changes to kinds, rooms, repositories, sites, wiki, jobs or rate. The relay addresses the request to the operator on the current grant and publishes it as a kind 1111 NIP-22 request. It never changes the grant automatically.73+74+The request appears in Notifications and Approvals with the current grant and the proposed replacement side by side. The operator reviews and signs the new kind 30392 grant. The relay checks that the request still names the current grant before applying it, so an older request cannot overwrite a newer decision. Agents cannot approve their own requests.75+7076 The rest of the relay's policy still applies. An agent cannot publish a kind the relay blocks, and a banned key stays banned whether or not it holds a grant.7177 7278 ## Pause and revoke
@@ -211,6 +217,33 @@ Over MCP, `request_job` builds a request, `job_feedback` and `job_result` build
211217 212218 A result, or feedback that reports `error` or `payment-required`, wakes the requester's devices in the mentions category with the body `job <kind> <status>`, where the kind is the request's.213219 220+## Agent chat features221+222+Agents can participate in room conversations using the same Nostr events as people. A kind 9 message is the interoperable reply form: include an `e` tag whose fourth value is `root`, and add `p` tags for people who should be notified. Kind 12 remains available for clients that use NIP-29's thread reply kind. The web UI groups replies under their root and preserves nested parent relationships. A grant must include the room and the kinds the agent will publish, such as `9`, `12`, `20001` and `20002`.223+224+An agent may publish ephemeral kind 20001 presence and kind 20002 typing events when its grant includes those kinds. These events are delivered live to authorized room members and are never written to history. They are useful for showing that an agent is working, but they are not a job record or a durable status. See Buzz's [Nostr event conventions](https://github.com/block/buzz/blob/main/NOSTR.md) for a compatible client reference.225+226+NIP-90 job requests, feedback and results appear as compact task cards above the room conversation. Approval requests published with the normal approval tags also appear there, and use the same signed answers and access rules as **Approvals**. A task card can show current progress, a result or an error without changing the underlying event protocol.227+228+The relay follows standard NIP-90 events for long tasks. Buzz's experimental 43001 to 43006 job kinds are not required for room chat or task cards; clients can continue to use their own support for those kinds when they need it.229+230+## Run a local agent231+232+The optional `tiny agent` command connects one agent key to a relay and runs an agent process for mentions in authorized rooms. It keeps a small on-disk queue journal, processes one batch per room at a time, and accepts either ACP version 1 or a simple text process. Pending work and work interrupted by a runner restart are returned to the queue; a task that completed before the journal was saved is deduplicated by event id.233+234+```sh235+tiny agent \236+ --relay wss://relay.example/ \237+ --room general \238+ --command /usr/local/bin/my-agent \239+ --state /var/lib/tiny-agent/queue.json \240+ --protocol acp241+```242+243+Set `TINY_AGENT_KEY` to the agent's hex secret or `nsec`, or choose another variable with `--key-env`. Repeat `--room` for each authorized room, `--arg` for an argument passed to the process, and use `--cwd` to set its working directory. The runner removes the selected key variable from the child environment; the child still runs with the machine permissions of its account. The default task and approval timeout is 30 minutes; change it with `--timeout`.244+245+ACP agents use the [Agent Client Protocol](https://agentclientprotocol.com/) version 1 over standard input and output. `--protocol command` sends the task prompt as text on standard input and reads the response as text from standard output. The runner does not start automatically when the relay daemon starts. Start it as a separate supervised process and grant its key access to the rooms, kind `9` and kind `20001`, plus `jobs: both`. The runner listens for kind 7 approval answers; it publishes approval requests as kind 9 messages rather than publishing kind 7 answers itself. Use `--room` once for each of one to 32 authorized rooms. The default pending queue is 32 mentions per room. Address the agent with `/cancel` or `/stop` to cancel your queued or active work in that room.246+214247 ## Static sites215248 216249 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.
M docs/fixi.md +1 -1
@@ -10,7 +10,7 @@ b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855
1010 1111 `/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Sync page uses Fixi's `fx-action` request for the job status stream, which renders server-escaped table HTML; `/connect.json` remains a JSON API for the configured connections. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them.1212 13-The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Encrypted uploads, NIP-34 publishing and NIP-17 file messages sign through the same bridge and never retry a signed request on their own. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.13+The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Encrypted uploads, NIP-34 publishing and NIP-17 file messages sign through the same bridge and never retry a signed request on their own. Fixi updates page links and GET forms within the current tenant without a full reload. Chat activity uses a bounded Fixi refresh and Paxi morph so task cards keep their open state while active inputs and signer actions remain protected. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.1414 1515 Signed-in browsers read the connection list and the query bridge with their session, so opening a page never asks the signer; signatures are requested only when publishing or decrypting. Signed actions require JavaScript and submit once while their controls are disabled. Connect previews update in place. Job status uses an authenticated stream that sends updates when the results change.1616
M docs/mcp.md +5 -0
@@ -116,6 +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_grant` | Ask the current grant operator for additional agent permissions with a short reason and additive changes. Returns a kind 1111 NIP-22 request for the agent to sign; the operator must review and publish the replacement kind 30392 grant. |119120 | `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. |120121 | `job_feedback` | Report progress on a long task with a kind 7000 event naming the request, the requester and a status. |121122 | `job_result` | Deliver a long task's output with an event of the request kind plus 1000, naming the request and the requester. |
@@ -134,6 +135,10 @@ An agent whose grant holds `propose` on a repository publishes issues, pull requ
134135 135136 The person answers with a kind 7 reaction to the published event from the asked key: `+` approves, `-` declines and any other content is their reply. Read the room or thread with `read_room` or `read_thread`, or query kind 7 events with `#e` set to the event id, to collect the answer. An `expiration` tag tells clients when the request lapses; the relay does not answer on the person's behalf.136137 138+### Requesting grant access139+140+`request_grant` is for an agent that needs a permission missing from its active grant. Pass `reason` (up to 500 characters) and `changes`, whose fields may include `kinds`, `rooms`, `repos`, `sites`, `wiki`, `jobs` and `rate`. Kinds and rooms are additive; repository and site entries replace entries with the same identity. The tool derives the current grant and operator, so it cannot target another agent or operator. It returns an unsigned kind 1111 event carrying NIP-22 references to the current kind 30392 grant and a `grant` JSON tag containing the proposed changes. Sign and submit that event through the same tool. Approval is a separately signed kind 30392 replacement from the operator and is shown in Approvals.141+137142 ### Static sites138143 139144 `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).
M docs/rooms.md +4 -0
@@ -65,10 +65,14 @@ One-to-one messages require a connected signer with NIP-44 support. The message
6565 6666 Open a room to read its messages, oldest first. Each message shows the author's name, their room role and the time; messages from agents carry an agent marker. Message text renders the common Markdown subset that people and agents type: paragraphs with line breaks, bold and italic, inline code and fenced code, lists, headings and links, and bare `https://` or `nostr:` references become links. Nothing else in a message is treated as markup. Links open in place and `nostr:` links resolve through the relay. A thread shows how many replies it has and opens on its own page, where replies read in order. Reactions appear under the message they answer, and an edited message shows its newest text with an edited marker. Choose **load earlier** for older messages.6767 68+For broad client compatibility, replies can use a kind 9 message with NIP-10 `e` tags: the root reference has marker `root` in the fourth tag value, and a nested reply can also identify its immediate parent. Kind 12 is accepted for clients that use the NIP-29 thread reply convention. Both forms are grouped into the same thread by the web UI.69+6870 Choose **Attach files**, paste files or drop them into the compose bar. A message can carry up to eight files, each from 1 byte through 32 MiB, with or without text. Files upload when you send. If sending fails, retrying reuses completed uploads. Relay quotas and agent grants also apply.6971 7072 Messages with NIP-92 `imeta` attachment tags and matching content references display images, video and audio inline, with download links for every file. Inline media also appears in threads, live messages and pages viewed without JavaScript. An edit replaces the message's attachment metadata along with its text.7173 74+Presence and typing are optional. A client publishes kind 20001 with an `h` tag and a short status such as `online`, `away` or `offline`; kind 20002 reports that typing started or stopped. Both are temporary live events and are not stored in room history. The web UI shares them only after you enable **Share presence and typing** on your Account page. This preference is saved for your account on the relay and applies across your browsers. Open Account by selecting your name in the footer; your relay lists and profile link are there too. Clients should treat them as hints that expire rather than as durable member state.75+7276 Room uploads follow the room's current access rule. A members-only room's files require room access even when someone knows the URL. Files may be shared with several rooms; access to any of those rooms permits reading. Deleting a room ends access through that room, including if someone later reuses its ID. Room attachments use access controls and are not end-to-end encrypted.7377 7478 Agents can call MCP `upload_attachment`, pass the returned descriptor to a room write tool and sign the resulting event to display generated media. `read_attachment` lets authorized agents retrieve incoming files. See [MCP room attachments](mcp.md#room-attachments).
A internal/agentrunner/acp_test.go +89 -0
@@ -0,0 +1,89 @@
1+package agentrunner2+3+import (4+ "bufio"5+ "context"6+ "encoding/json"7+ "errors"8+ "os"9+ "path/filepath"10+ "testing"11+ "time"12+)13+14+func TestACPHandshakeAndStream(t *testing.T) {15+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)16+ defer cancel()17+ result, err := RunProcess(ctx, ProcessOptions{Command: os.Args[0], Args: []string{"-test.run=TestACPHelper"}, Env: append(os.Environ(), "TINY_ACP_HELPER=1"), CWD: t.TempDir(), Protocol: "acp"}, "hello")18+ if err != nil || result != "hello from agent" {19+ t.Fatalf("result=%q err=%v", result, err)20+ }21+}22+23+func TestACPCancellationNotifiesAgentBeforeExit(t *testing.T) {24+ marker := filepath.Join(t.TempDir(), "cancelled")25+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)26+ defer cancel()27+ _, err := RunProcess(ctx, ProcessOptions{Command: os.Args[0], Args: []string{"-test.run=TestACPHelper"}, Env: append(os.Environ(), "TINY_ACP_HELPER=1", "TINY_ACP_CANCEL_HELPER=1", "TINY_ACP_CANCEL_MARKER="+marker), CWD: t.TempDir(), Protocol: "acp"}, "hello")28+ if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {29+ t.Fatalf("err=%v", err)30+ }31+ if _, err := os.Stat(marker); err != nil {32+ t.Fatalf("agent did not receive cancellation: %v", err)33+ }34+}35+36+func TestCommandCleansUpDescendantProcess(t *testing.T) {37+ marker := filepath.Join(t.TempDir(), "child-survived")38+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)39+ defer cancel()40+ _, err := RunProcess(ctx, ProcessOptions{Command: "sh", Args: []string{"-c", "(sleep 0.5; touch \"$1\") & wait", "descendant", marker}, CWD: t.TempDir(), Protocol: "command"}, "hello")41+ if err == nil {42+ t.Fatal("expected cancellation")43+ }44+ time.Sleep(200 * time.Millisecond)45+ if _, err := os.Stat(marker); err == nil {46+ t.Fatal("descendant survived")47+ }48+}49+func TestACPHelper(t *testing.T) {50+ if os.Getenv("TINY_ACP_HELPER") != "1" && os.Getenv("TINY_ACP_CANCEL_HELPER") != "1" {51+ return52+ }53+ scan := bufio.NewScanner(os.Stdin)54+ encoder := json.NewEncoder(os.Stdout)55+ for scan.Scan() {56+ var message map[string]any57+ if json.Unmarshal(scan.Bytes(), &message) != nil {58+ os.Exit(2)59+ }60+ params, _ := message["params"].(map[string]any)61+ switch message["method"] {62+ case "initialize":63+ if params["protocolVersion"] != float64(1) {64+ os.Exit(3)65+ }66+ encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"protocolVersion": 1}})67+ case "session/new":68+ if params["cwd"] == nil || params["mcpServers"] == nil {69+ os.Exit(4)70+ }71+ encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"sessionId": "test-session"}})72+ case "session/prompt":73+ if params["sessionId"] != "test-session" || params["prompt"] == nil {74+ os.Exit(5)75+ }76+ if os.Getenv("TINY_ACP_CANCEL_HELPER") == "1" {77+ continue78+ }79+ encoder.Encode(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "test-session", "update": map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "hello from agent"}}}})80+ encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"stopReason": "end_turn"}})81+ case "session/cancel":82+ if marker := os.Getenv("TINY_ACP_CANCEL_MARKER"); marker != "" {83+ _ = os.WriteFile(marker, []byte("cancelled"), 0600)84+ }85+ os.Exit(0)86+ }87+ }88+ os.Exit(0)89+}
A internal/agentrunner/process.go +325 -0
@@ -0,0 +1,325 @@
1+package agentrunner2+3+import (4+ "bufio"5+ "bytes"6+ "context"7+ "encoding/json"8+ "errors"9+ "fmt"10+ "io"11+ "os/exec"12+ "strings"13+ "sync"14+ "time"15+)16+17+type PermissionOption struct {18+ ID string `json:"optionId"`19+ Name string `json:"name"`20+ Kind string `json:"kind"`21+}22+type Permission struct {23+ Title string24+ Details string25+ Options []PermissionOption26+}27+type ProcessOptions struct {28+ Command string29+ Args []string30+ Env []string31+ CWD string32+ Protocol string33+ Progress func(string)34+ Permission func(context.Context, Permission) (string, error)35+}36+type rpcFrame struct {37+ ID json.RawMessage `json:"id,omitempty"`38+ Method string `json:"method,omitempty"`39+ Params json.RawMessage `json:"params,omitempty"`40+ Result json.RawMessage `json:"result,omitempty"`41+ Error *rpcError `json:"error,omitempty"`42+}43+type rpcError struct {44+ Code int `json:"code"`45+ Message string `json:"message"`46+}47+type rpcProcess struct {48+ input io.WriteCloser49+ frames chan rpcFrame50+ finished chan struct{}51+ readErr error52+ writeMu sync.Mutex53+ cancelOnce sync.Once54+ sessionMu sync.RWMutex55+ opts ProcessOptions56+ session string57+ text strings.Builder58+}59+60+func RunProcess(ctx context.Context, opts ProcessOptions, prompt string) (string, error) {61+ if opts.Command == "" {62+ return "", errors.New("agent command is required")63+ }64+ if opts.Protocol == "command" {65+ return runCommand(ctx, opts, prompt)66+ }67+ if opts.Protocol != "acp" {68+ return "", errors.New("agent protocol must be acp or command")69+ }70+ command := exec.CommandContext(ctx, opts.Command, opts.Args...)71+ configureProcessGroup(command)72+ var process *rpcProcess73+ command.Cancel = func() error {74+ if process != nil && process.hasSession() {75+ process.cancelSession()76+ }77+ terminateProcess(command)78+ return nil79+ }80+ command.Dir = opts.CWD81+ command.Env = opts.Env82+ command.WaitDelay = 2 * time.Second83+ input, err := command.StdinPipe()84+ if err != nil {85+ return "", err86+ }87+ output, err := command.StdoutPipe()88+ if err != nil {89+ return "", err90+ }91+ // Agent stderr belongs to the local runner, never to a room transcript.92+ command.Stderr = io.Discard93+ p := &rpcProcess{input: input, frames: make(chan rpcFrame, 1), finished: make(chan struct{}), opts: opts}94+ process = p95+ if err := command.Start(); err != nil {96+ return "", fmt.Errorf("start agent: %w", err)97+ }98+ readCtx, stopRead := context.WithCancel(ctx)99+ go p.read(readCtx, output)100+ defer func() { stopRead(); input.Close(); terminateProcess(command); command.Wait(); <-p.finished }()101+ init, err := p.call(ctx, 1, "initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{}, "clientInfo": map[string]string{"name": "tiny", "version": "1"}})102+ if err != nil {103+ return "", err104+ }105+ var initialized struct {106+ Version int `json:"protocolVersion"`107+ }108+ if json.Unmarshal(init, &initialized) != nil || initialized.Version != 1 {109+ return "", errors.New("agent does not support ACP version 1")110+ }111+ raw, err := p.call(ctx, 2, "session/new", map[string]any{"cwd": opts.CWD, "mcpServers": []any{}})112+ if err != nil {113+ return "", err114+ }115+ var session struct {116+ ID string `json:"sessionId"`117+ }118+ if json.Unmarshal(raw, &session) != nil || session.ID == "" {119+ return "", errors.New("agent returned no session id")120+ }121+ p.setSession(session.ID)122+ raw, err = p.call(ctx, 3, "session/prompt", map[string]any{"sessionId": session.ID, "prompt": []any{map[string]string{"type": "text", "text": prompt}}})123+ if err != nil {124+ if ctx.Err() != nil {125+ p.cancelSession()126+ }127+ return "", err128+ }129+ var done struct {130+ Reason string `json:"stopReason"`131+ }132+ if json.Unmarshal(raw, &done) != nil {133+ return "", errors.New("invalid ACP prompt result")134+ }135+ if done.Reason == "cancelled" {136+ return "", context.Canceled137+ }138+ return strings.TrimSpace(p.text.String()), nil139+}140+func (p *rpcProcess) read(ctx context.Context, out io.Reader) {141+ defer close(p.finished)142+ defer close(p.frames)143+ scanner := bufio.NewScanner(out)144+ scanner.Buffer(make([]byte, 4096), 1<<20)145+ for scanner.Scan() {146+ var frame rpcFrame147+ if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil {148+ p.readErr = fmt.Errorf("invalid ACP response: %w", err)149+ return150+ }151+ select {152+ case p.frames <- frame:153+ case <-ctx.Done():154+ return155+ }156+ }157+ p.readErr = scanner.Err()158+}159+func (p *rpcProcess) write(value any) error {160+ p.writeMu.Lock()161+ defer p.writeMu.Unlock()162+ return json.NewEncoder(p.input).Encode(value)163+}164+func (p *rpcProcess) call(ctx context.Context, id int, method string, params any) (json.RawMessage, error) {165+ if err := p.write(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil {166+ return nil, err167+ }168+ for {169+ select {170+ case <-ctx.Done():171+ if method == "session/prompt" && p.session != "" {172+ p.cancelSession()173+ }174+ return nil, ctx.Err()175+ case frame, ok := <-p.frames:176+ if !ok {177+ if p.readErr != nil {178+ return nil, p.readErr179+ }180+ return nil, errors.New("agent process exited before replying")181+ }182+ if frame.Method != "" {183+ if err := p.receive(ctx, frame); err != nil {184+ return nil, err185+ }186+ continue187+ }188+ if string(frame.ID) != fmt.Sprint(id) {189+ continue190+ }191+ if frame.Error != nil {192+ return nil, fmt.Errorf("ACP %s: %s", method, frame.Error.Message)193+ }194+ return frame.Result, nil195+ }196+ }197+}198+199+func (p *rpcProcess) cancelSession() {200+ p.cancelOnce.Do(func() {201+ session := p.getSession()202+ if session == "" {203+ return204+ }205+ done := make(chan struct{})206+ go func() {207+ defer close(done)208+ _ = p.write(map[string]any{"jsonrpc": "2.0", "method": "session/cancel", "params": map[string]string{"sessionId": session}})209+ }()210+ timer := time.NewTimer(100 * time.Millisecond)211+ defer timer.Stop()212+ select {213+ case <-done:214+ <-timer.C215+ case <-timer.C:216+ // Closing the pipe releases a blocked write before we join its goroutine.217+ _ = p.input.Close()218+ <-done219+ }220+ })221+}222+223+func (p *rpcProcess) setSession(session string) {224+ p.sessionMu.Lock()225+ p.session = session226+ p.sessionMu.Unlock()227+}228+func (p *rpcProcess) getSession() string {229+ p.sessionMu.RLock()230+ defer p.sessionMu.RUnlock()231+ return p.session232+}233+func (p *rpcProcess) hasSession() bool { return p.getSession() != "" }234+func (p *rpcProcess) receive(ctx context.Context, frame rpcFrame) error {235+ var params struct {236+ Session string `json:"sessionId"`237+ Update struct {238+ Type string `json:"sessionUpdate"`239+ Title string `json:"title"`240+ Status string `json:"status"`241+ Content struct {242+ Type string `json:"type"`243+ Text string `json:"text"`244+ } `json:"content"`245+ } `json:"update"`246+ Tool struct {247+ Title string `json:"title"`248+ Input json.RawMessage `json:"rawInput"`249+ } `json:"toolCall"`250+ Options []PermissionOption `json:"options"`251+ }252+ if json.Unmarshal(frame.Params, &params) != nil {253+ return errors.New("invalid ACP parameters")254+ }255+ if params.Session != "" && params.Session != p.session {256+ return errors.New("ACP update belongs to another session")257+ }258+ if frame.Method == "session/update" {259+ if params.Update.Type == "agent_message_chunk" && params.Update.Content.Type == "text" {260+ if p.text.Len()+len(params.Update.Content.Text) > 64000 {261+ return errors.New("agent reply exceeds 64 KiB")262+ }263+ p.text.WriteString(params.Update.Content.Text)264+ }265+ if (params.Update.Type == "tool_call" || params.Update.Type == "tool_call_update") && p.opts.Progress != nil {266+ p.opts.Progress(strings.TrimSpace(params.Update.Title + " " + params.Update.Status))267+ }268+ return nil269+ }270+ if len(frame.ID) == 0 {271+ return nil272+ }273+ if frame.Method != "session/request_permission" {274+ return p.write(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "error": rpcError{Code: -32601, Message: "Client capability unavailable"}})275+ }276+ outcome := map[string]string{"outcome": "cancelled"}277+ if p.opts.Permission != nil {278+ if len(params.Tool.Input) > 16000 {279+ return errors.New("permission request is too large to review")280+ }281+ choice, err := p.opts.Permission(ctx, Permission{Title: params.Tool.Title, Details: string(params.Tool.Input), Options: params.Options})282+ if err != nil {283+ return err284+ }285+ for _, option := range params.Options {286+ if option.ID == choice {287+ outcome = map[string]string{"outcome": "selected", "optionId": choice}288+ break289+ }290+ }291+ }292+ return p.write(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "result": map[string]any{"outcome": outcome}})293+}294+295+type boundedOutput struct {296+ bytes.Buffer297+ limit int298+}299+300+func (b *boundedOutput) Write(p []byte) (int, error) {301+ if b.Len()+len(p) > b.limit {302+ return 0, errors.New("agent output exceeds 64 KiB")303+ }304+ return b.Buffer.Write(p)305+}306+func runCommand(ctx context.Context, opts ProcessOptions, prompt string) (string, error) {307+ command := exec.CommandContext(ctx, opts.Command, opts.Args...)308+ configureProcessGroup(command)309+ command.Cancel = func() error {310+ terminateProcess(command)311+ return nil312+ }313+ command.Dir = opts.CWD314+ command.Env = opts.Env315+ command.WaitDelay = 2 * time.Second316+ command.Stdin = strings.NewReader(prompt)317+ output := &boundedOutput{limit: 64000}318+ command.Stdout = output319+ command.Stderr = io.Discard320+ defer terminateProcess(command)321+ if err := command.Run(); err != nil {322+ return "", fmt.Errorf("agent command failed: %w", err)323+ }324+ return strings.TrimSpace(output.String()), nil325+}
A internal/agentrunner/process_unix.go +21 -0
A internal/agentrunner/process_windows.go +13 -0
A internal/agentrunner/relay.go +226 -0
@@ -0,0 +1,226 @@
1+package agentrunner2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "net/url"9+ "slices"10+ "strings"11+ "sync"12+ "time"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/coder/websocket"16+)17+18+type RelayClient struct {19+ URL, Secret, PubKey string20+ Rooms []string21+ OnEvent func(event.Event)22+ OnReady func()23+ OnError func(error)24+ mu sync.Mutex25+ conn *websocket.Conn26+ connection context.Context27+ pending map[string]chan error28+}29+30+func (c *RelayClient) Run(ctx context.Context) error {31+ parsed, err := url.Parse(c.URL)32+ if err != nil || parsed.Host == "" || (parsed.Scheme != "ws" && parsed.Scheme != "wss") || parsed.User != nil || parsed.Fragment != "" {33+ return errors.New("agent relay must be a ws or wss URL")34+ }35+ c.PubKey, err = event.PublicKey(c.Secret)36+ if err != nil {37+ return err38+ }39+ delay := time.Second40+ for ctx.Err() == nil {41+ err = c.connect(ctx)42+ if ctx.Err() != nil {43+ break44+ }45+ if c.OnError != nil {46+ c.OnError(err)47+ }48+ timer := time.NewTimer(delay)49+ select {50+ case <-ctx.Done():51+ timer.Stop()52+ return ctx.Err()53+ case <-timer.C:54+ }55+ delay = min(delay*2, 30*time.Second)56+ }57+ return ctx.Err()58+}59+func (c *RelayClient) connect(ctx context.Context) error {60+ conn, _, err := websocket.Dial(ctx, c.URL, nil)61+ if err != nil {62+ return err63+ }64+ conn.SetReadLimit(1 << 20)65+ scope, cancel := context.WithCancel(ctx)66+ defer cancel()67+ defer conn.CloseNow()68+ c.mu.Lock()69+ c.conn = conn70+ c.connection = scope71+ c.pending = map[string]chan error{}72+ c.mu.Unlock()73+ defer func() {74+ c.mu.Lock()75+ if c.conn == conn {76+ c.conn = nil77+ }78+ c.mu.Unlock()79+ }()80+ if err := c.subscribe(scope, conn); err != nil {81+ return err82+ }83+ authID := ""84+ for {85+ _, raw, err := conn.Read(scope)86+ if err != nil {87+ return err88+ }89+ var frame []json.RawMessage90+ if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 {91+ continue92+ }93+ var kind string94+ json.Unmarshal(frame[0], &kind)95+ switch kind {96+ case "AUTH":97+ var challenge string98+ if json.Unmarshal(frame[1], &challenge) != nil {99+ continue100+ }101+ auth := event.Event{Kind: 22242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"relay", c.URL}, {"challenge", challenge}}}102+ if err := event.Sign(&auth, c.Secret); err != nil {103+ return err104+ }105+ authID = auth.ID106+ if err := writeRelay(scope, conn, []any{"AUTH", auth}); err != nil {107+ return err108+ }109+ case "OK":110+ if len(frame) < 3 {111+ continue112+ }113+ var id string114+ var ok bool115+ var reason string116+ json.Unmarshal(frame[1], &id)117+ json.Unmarshal(frame[2], &ok)118+ if len(frame) > 3 {119+ json.Unmarshal(frame[3], &reason)120+ }121+ if id == authID {122+ if !ok {123+ return fmt.Errorf("agent authentication refused: %s", reason)124+ }125+ if err := c.subscribe(scope, conn); err != nil {126+ return err127+ }128+ if c.OnReady != nil {129+ c.OnReady()130+ }131+ continue132+ }133+ var result error134+ if !ok && !strings.HasPrefix(reason, "duplicate:") {135+ result = fmt.Errorf("relay refused event: %s", reason)136+ }137+ c.mu.Lock()138+ wait := c.pending[id]139+ c.mu.Unlock()140+ if wait != nil {141+ select {142+ case wait <- result:143+ default:144+ }145+ }146+ case "EOSE":147+ if c.OnReady != nil {148+ c.OnReady()149+ }150+ case "EVENT":151+ if len(frame) != 3 {152+ continue153+ }154+ e, err := event.Parse(frame[2])155+ if err != nil {156+ continue157+ }158+ if c.OnEvent != nil {159+ c.OnEvent(e)160+ }161+ case "CLOSED":162+ if len(frame) > 2 {163+ var reason string164+ json.Unmarshal(frame[2], &reason)165+ if !strings.HasPrefix(reason, "auth-required:") {166+ return fmt.Errorf("agent subscription closed: %s", reason)167+ }168+ }169+ }170+ }171+}172+func (c *RelayClient) subscribe(ctx context.Context, conn *websocket.Conn) error {173+ since := time.Now().Add(-5 * time.Minute).Unix()174+ return writeRelay(ctx, conn, []any{"REQ", "tiny-agent", map[string]any{"kinds": []int{9, 11, 12}, "#h": c.Rooms, "#p": []string{c.PubKey}, "since": since}, map[string]any{"kinds": []int{7, 1111}, "#p": []string{c.PubKey}, "since": since}})175+}176+func writeRelay(ctx context.Context, conn *websocket.Conn, value any) error {177+ raw, err := json.Marshal(value)178+ if err != nil {179+ return err180+ }181+ return conn.Write(ctx, websocket.MessageText, raw)182+}183+func (c *RelayClient) Publish(ctx context.Context, e event.Event) (event.Event, error) {184+ if e.CreatedAt == 0 {185+ e.CreatedAt = time.Now().Unix()186+ }187+ if err := event.Sign(&e, c.Secret); err != nil {188+ return e, err189+ }190+ c.mu.Lock()191+ conn, scope := c.conn, c.connection192+ if conn == nil {193+ c.mu.Unlock()194+ return e, errors.New("agent relay is disconnected")195+ }196+ wait := make(chan error, 1)197+ c.pending[e.ID] = wait198+ c.mu.Unlock()199+ defer func() { c.mu.Lock(); delete(c.pending, e.ID); c.mu.Unlock() }()200+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)201+ defer cancel()202+ if err := writeRelay(ctx, conn, []any{"EVENT", e}); err != nil {203+ return e, err204+ }205+ select {206+ case err := <-wait:207+ return e, err208+ case <-ctx.Done():209+ return e, ctx.Err()210+ case <-scope.Done():211+ return e, errors.New("agent relay disconnected before acknowledgement")212+ }213+}214+func (c *RelayClient) Mention(e event.Event) (Mention, bool) {215+ if e.PubKey == c.PubKey || !slices.Contains([]int{9, 11, 12}, e.Kind) || !slices.Contains(event.TagValues(e, "p"), c.PubKey) || !slices.Contains(c.Rooms, event.Tag(e, "h")) {216+ return Mention{}, false217+ }218+ if e.CreatedAt > time.Now().Add(time.Minute).Unix() || e.CreatedAt < time.Now().Add(-24*time.Hour).Unix() || len(e.Content) > 64000 {219+ return Mention{}, false220+ }221+ root := event.RoomReplyRoot(e)222+ if root == "" {223+ root = e.ID224+ }225+ return Mention{Room: event.Tag(e, "h"), EventID: e.ID, Author: e.PubKey, Prompt: e.Content, Root: root, Kind: e.Kind, CreatedAt: e.CreatedAt}, true226+}
A internal/agentrunner/runner.go +375 -0
@@ -0,0 +1,375 @@
1+// Package agentrunner runs an optional agent process outside the relay daemon.2+package agentrunner3+4+import (5+ "context"6+ "encoding/json"7+ "errors"8+ "fmt"9+ "os"10+ "path/filepath"11+ "sync"12+ "time"13+)14+15+var (16+ ErrDuplicate = errors.New("agent runner: duplicate event")17+ ErrUnauthorizedRoom = errors.New("agent runner: unauthorized room")18+ ErrQueueFull = errors.New("agent runner: room queue full")19+ ErrStopped = errors.New("agent runner: stopped")20+)21+22+type Mention struct {23+ Room string `json:"room"`24+ EventID string `json:"event_id"`25+ Author string `json:"author"`26+ Prompt string `json:"prompt"`27+ Root string `json:"root"`28+ Kind int `json:"kind"`29+ CreatedAt int64 `json:"created_at"`30+}31+type Handler func(context.Context, []Mention) error32+type Options struct {33+ AllowedRooms []string34+ QueueLimit int35+ StatePath string36+ Handle Handler37+ OnError func(error)38+}39+type roomQueue struct {40+ pending, active []Mention41+ cancel context.CancelFunc42+ wake chan struct{}43+}44+type journal struct {45+ Pending map[string][]Mention `json:"pending"`46+ Seen []string `json:"seen"`47+}48+type Runner struct {49+ mu sync.Mutex50+ rooms map[string]*roomQueue51+ seen map[string]bool52+ order []string53+ opts Options54+ ctx context.Context55+ cancel context.CancelFunc56+ wg sync.WaitGroup57+ stopped bool58+}59+60+func New(opts Options) (*Runner, error) {61+ if opts.Handle == nil || len(opts.AllowedRooms) == 0 || len(opts.AllowedRooms) > 32 {62+ return nil, errors.New("agent runner: a handler and 1 to 32 rooms are required")63+ }64+ if opts.QueueLimit <= 0 {65+ opts.QueueLimit = 3266+ }67+ if opts.QueueLimit > 256 {68+ return nil, errors.New("agent runner: queue limit cannot exceed 256")69+ }70+ r := &Runner{opts: opts, rooms: map[string]*roomQueue{}, seen: map[string]bool{}}71+ for _, room := range opts.AllowedRooms {72+ if room == "" {73+ return nil, ErrUnauthorizedRoom74+ }75+ r.rooms[room] = &roomQueue{wake: make(chan struct{}, 1)}76+ }77+ if err := r.restore(); err != nil {78+ return nil, err79+ }80+ return r, nil81+}82+func (r *Runner) Start(ctx context.Context) {83+ r.mu.Lock()84+ defer r.mu.Unlock()85+ if r.ctx != nil || r.stopped {86+ return87+ }88+ r.ctx, r.cancel = context.WithCancel(ctx)89+ for room, q := range r.rooms {90+ r.wg.Add(1)91+ go r.work(room, q)92+ }93+}94+func (r *Runner) Enqueue(m Mention) error {95+ if m.EventID == "" || m.Author == "" || len(m.Prompt) > 64*1024 {96+ return errors.New("agent runner: invalid mention")97+ }98+ r.mu.Lock()99+ defer r.mu.Unlock()100+ if r.stopped {101+ return ErrStopped102+ }103+ q, ok := r.rooms[m.Room]104+ if !ok {105+ return ErrUnauthorizedRoom106+ }107+ if r.seen[m.EventID] {108+ return ErrDuplicate109+ }110+ if len(q.pending) >= r.opts.QueueLimit {111+ return ErrQueueFull112+ }113+ q.pending = append(q.pending, m)114+ r.seen[m.EventID] = true115+ r.order = append(r.order, m.EventID)116+ if err := r.save(); err != nil {117+ q.pending = q.pending[:len(q.pending)-1]118+ delete(r.seen, m.EventID)119+ r.order = r.order[:len(r.order)-1]120+ return err121+ }122+ select {123+ case q.wake <- struct{}{}:124+ default:125+ }126+ return nil127+}128+func (r *Runner) CancelFrom(room, author string) bool {129+ r.mu.Lock()130+ defer r.mu.Unlock()131+ q := r.rooms[room]132+ if q == nil {133+ return false134+ }135+ allowed := false136+ for _, m := range append(append([]Mention{}, q.active...), q.pending...) {137+ if author == "" || m.Author == author {138+ allowed = true139+ break140+ }141+ }142+ if !allowed {143+ return false144+ }145+ filtered := q.pending[:0]146+ for _, m := range q.pending {147+ if author == "" || m.Author == author {148+ continue149+ }150+ filtered = append(filtered, m)151+ }152+ q.pending = filtered153+ if q.cancel != nil && anyAuthor(q.active, author) {154+ q.cancel()155+ }156+ if err := r.save(); err != nil {157+ r.report(err)158+ }159+ return true160+}161+func (r *Runner) Cancel(room string) error { r.CancelFrom(room, ""); return nil }162+func (r *Runner) Stop() {163+ r.mu.Lock()164+ r.stopped = true165+ if r.cancel != nil {166+ r.cancel()167+ }168+ r.mu.Unlock()169+ r.wg.Wait()170+}171+func (r *Runner) work(room string, q *roomQueue) {172+ defer r.wg.Done()173+ for {174+ select {175+ case <-r.ctx.Done():176+ return177+ default:178+ }179+ r.mu.Lock()180+ if len(q.pending) == 0 {181+ r.mu.Unlock()182+ select {183+ case <-r.ctx.Done():184+ return185+ case <-q.wake:186+ }187+ continue188+ }189+ batch := takeBatch(q.pending)190+ q.pending = q.pending[len(batch):]191+ q.active = batch192+ ctx, cancel := context.WithCancel(r.ctx)193+ q.cancel = cancel194+ if err := r.save(); err != nil {195+ r.report(err)196+ cancel()197+ q.pending = append(batch, q.pending...)198+ q.active = nil199+ q.cancel = nil200+ r.mu.Unlock()201+ return202+ }203+ r.mu.Unlock()204+ err := r.opts.Handle(ctx, batch)205+ cancel()206+ r.mu.Lock()207+ if r.ctx.Err() != nil {208+ q.pending = append(batch, q.pending...)209+ }210+ q.active = nil211+ q.cancel = nil212+ if saveErr := r.save(); saveErr != nil {213+ r.report(saveErr)214+ }215+ r.mu.Unlock()216+ if err != nil && !errors.Is(err, context.Canceled) {217+ r.report(fmt.Errorf("agent room %s: %w", room, err))218+ }219+ }220+}221+222+func takeBatch(pending []Mention) []Mention {223+ if len(pending) == 0 {224+ return nil225+ }226+ root := pending[0].Root227+ n := 1228+ for n < len(pending) && pending[n].Root == root {229+ n++230+ }231+ return append([]Mention(nil), pending[:n]...)232+}233+234+func anyAuthor(events []Mention, author string) bool {235+ if author == "" {236+ return len(events) > 0237+ }238+ for _, event := range events {239+ if event.Author == author {240+ return true241+ }242+ }243+ return false244+}245+func (r *Runner) report(err error) {246+ if r.opts.OnError != nil {247+ r.opts.OnError(err)248+ }249+}250+func (r *Runner) restore() error {251+ if r.opts.StatePath == "" {252+ return nil253+ }254+ raw, err := os.ReadFile(r.opts.StatePath)255+ if errors.Is(err, os.ErrNotExist) {256+ return nil257+ }258+ if err != nil {259+ return fmt.Errorf("read agent state: %w", err)260+ }261+ if len(raw) > 32<<20 {262+ return errors.New("agent state exceeds 32 MiB")263+ }264+ var state journal265+ if err := json.Unmarshal(raw, &state); err != nil {266+ return fmt.Errorf("decode agent state: %w", err)267+ }268+ for _, id := range state.Seen {269+ if !r.seen[id] {270+ r.seen[id] = true271+ r.order = append(r.order, id)272+ }273+ }274+ for room, queue := range state.Pending {275+ q := r.rooms[room]276+ if q == nil {277+ return errors.New("agent state contains an unconfigured room")278+ }279+ if len(queue) > r.opts.QueueLimit*2 {280+ return ErrQueueFull281+ }282+ q.pending = queue283+ }284+ return nil285+}286+func (r *Runner) save() error {287+ if r.opts.StatePath == "" {288+ return nil289+ }290+ // Keep a bounded replay history while retaining every queued event.291+ active := map[string]bool{}292+ pending := map[string][]Mention{}293+ for room, q := range r.rooms {294+ pending[room] = append(append([]Mention{}, q.active...), q.pending...)295+ for _, m := range pending[room] {296+ active[m.EventID] = true297+ }298+ }299+ if len(r.order) > 10000 {300+ keep := make(map[string]bool, len(active)+10000)301+ for id := range active {302+ keep[id] = true303+ }304+ remaining := 10000 - len(active)305+ if remaining < 0 {306+ remaining = 0307+ }308+ for i := len(r.order) - 1; i >= 0 && remaining > 0; i-- {309+ if !keep[r.order[i]] {310+ keep[r.order[i]] = true311+ remaining--312+ }313+ }314+ kept := make([]string, 0, len(keep))315+ for _, id := range r.order {316+ if keep[id] {317+ kept = append(kept, id)318+ }319+ }320+ for id := range r.seen {321+ if !keep[id] {322+ delete(r.seen, id)323+ }324+ }325+ r.order = kept326+ }327+ raw, err := json.Marshal(journal{Pending: pending, Seen: r.order})328+ if err != nil {329+ return err330+ }331+ dir := filepath.Dir(r.opts.StatePath)332+ if err := os.MkdirAll(dir, 0700); err != nil {333+ return err334+ }335+ file, err := os.CreateTemp(dir, ".tiny-agent-*")336+ if err != nil {337+ return err338+ }339+ name := file.Name()340+ defer os.Remove(name)341+ if _, err = file.Write(raw); err != nil {342+ file.Close()343+ return err344+ }345+ if err = file.Sync(); err != nil {346+ file.Close()347+ return err348+ }349+ if err = file.Close(); err != nil {350+ return err351+ }352+ return os.Rename(name, r.opts.StatePath)353+}354+355+// Drain waits until queued work has finished, or the caller cancels.356+func (r *Runner) Drain(ctx context.Context) error {357+ ticker := time.NewTicker(10 * time.Millisecond)358+ defer ticker.Stop()359+ for {360+ r.mu.Lock()361+ empty := true362+ for _, q := range r.rooms {363+ empty = empty && len(q.pending) == 0 && len(q.active) == 0364+ }365+ r.mu.Unlock()366+ if empty {367+ return nil368+ }369+ select {370+ case <-ctx.Done():371+ return ctx.Err()372+ case <-ticker.C:373+ }374+ }375+}
A internal/agentrunner/runner_test.go +196 -0
@@ -0,0 +1,196 @@
1+package agentrunner2+3+import (4+ "context"5+ "errors"6+ "path/filepath"7+ "sync"8+ "testing"9+ "time"10+)11+12+func TestRunnerSerializesBatchesAndOtherRoomsProgress(t *testing.T) {13+ start := make(chan string, 3)14+ release := make(chan struct{})15+ var mu sync.Mutex16+ var batches [][]Mention17+ r, err := New(Options{AllowedRooms: []string{"one", "two"}, Handle: func(ctx context.Context, b []Mention) error {18+ mu.Lock()19+ batches = append(batches, b)20+ mu.Unlock()21+ start <- b[0].EventID22+ if b[0].EventID == "a" {23+ select {24+ case <-ctx.Done():25+ return ctx.Err()26+ case <-release:27+ }28+ }29+ return nil30+ }})31+ if err != nil {32+ t.Fatal(err)33+ }34+ r.Start(context.Background())35+ defer r.Stop()36+ add := func(id, room string) {37+ t.Helper()38+ if err := r.Enqueue(Mention{EventID: id, Author: "alice", Room: room}); err != nil {39+ t.Fatal(err)40+ }41+ }42+ add("a", "one")43+ <-start44+ add("b", "one")45+ add("c", "one")46+ add("d", "two")47+ select {48+ case id := <-start:49+ if id != "d" {50+ t.Fatal(id)51+ }52+ case <-time.After(time.Second):53+ t.Fatal("second room blocked")54+ }55+ if err := r.Enqueue(Mention{EventID: "b", Author: "alice", Room: "one"}); !errors.Is(err, ErrDuplicate) {56+ t.Fatal(err)57+ }58+ close(release)59+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)60+ defer cancel()61+ if err := r.Drain(ctx); err != nil {62+ t.Fatal(err)63+ }64+ mu.Lock()65+ defer mu.Unlock()66+ if len(batches) != 3 || len(batches[2]) != 2 {67+ t.Fatalf("batches=%v", batches)68+ }69+}70+func TestRunnerStopsActiveTaskAndRestoresJournal(t *testing.T) {71+ path := filepath.Join(t.TempDir(), "queue.json")72+ started := make(chan struct{})73+ r, err := New(Options{AllowedRooms: []string{"one"}, StatePath: path, Handle: func(ctx context.Context, b []Mention) error { close(started); <-ctx.Done(); return ctx.Err() }})74+ if err != nil {75+ t.Fatal(err)76+ }77+ r.Start(context.Background())78+ r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "a"})79+ <-started80+ if r.CancelFrom("one", "other") {81+ t.Fatal("another author canceled work")82+ }83+ done := make(chan struct{})84+ go func() { r.Stop(); close(done) }()85+ select {86+ case <-done:87+ case <-time.After(time.Second):88+ t.Fatal("stop hung")89+ }90+ if err := r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "b"}); !errors.Is(err, ErrStopped) {91+ t.Fatal(err)92+ }93+ got := make(chan string, 1)94+ restored, err := New(Options{AllowedRooms: []string{"one"}, StatePath: path, Handle: func(ctx context.Context, b []Mention) error { got <- b[0].EventID; return nil }})95+ if err != nil {96+ t.Fatal(err)97+ }98+ restored.Start(context.Background())99+ defer restored.Stop()100+ select {101+ case id := <-got:102+ if id != "a" {103+ t.Fatal(id)104+ }105+ case <-time.After(time.Second):106+ t.Fatal("unfinished work not recovered")107+ }108+}109+func TestRunnerRejectsUnconfiguredRoomsAndRecoversAfterFailure(t *testing.T) {110+ got := make(chan string, 2)111+ r, err := New(Options{AllowedRooms: []string{"one"}, QueueLimit: 1, Handle: func(ctx context.Context, b []Mention) error {112+ got <- b[0].EventID113+ return errors.New("process crashed")114+ }})115+ if err != nil {116+ t.Fatal(err)117+ }118+ if err := r.Enqueue(Mention{Room: "two", Author: "a", EventID: "a"}); !errors.Is(err, ErrUnauthorizedRoom) {119+ t.Fatal(err)120+ }121+ r.Enqueue(Mention{Room: "one", Author: "a", EventID: "a"})122+ if err := r.Enqueue(Mention{Room: "one", Author: "a", EventID: "b"}); !errors.Is(err, ErrQueueFull) {123+ t.Fatal(err)124+ }125+ r.Start(context.Background())126+ defer r.Stop()127+ <-got128+ r.Enqueue(Mention{Room: "one", Author: "a", EventID: "b"})129+ select {130+ case <-got:131+ case <-time.After(time.Second):132+ t.Fatal("queue died after process crash")133+ }134+}135+136+func TestRunnerDoesNotMixThreadBatches(t *testing.T) {137+ got := make(chan []Mention, 2)138+ r, err := New(Options{AllowedRooms: []string{"one"}, Handle: func(ctx context.Context, batch []Mention) error {139+ got <- batch140+ return nil141+ }})142+ if err != nil {143+ t.Fatal(err)144+ }145+ r.Start(context.Background())146+ defer r.Stop()147+ for _, m := range []Mention{{Room: "one", Author: "a", EventID: "a", Root: "root-a"}, {Room: "one", Author: "b", EventID: "b", Root: "root-a"}, {Room: "one", Author: "c", EventID: "c", Root: "root-b"}} {148+ if err := r.Enqueue(m); err != nil {149+ t.Fatal(err)150+ }151+ }152+ first := <-got153+ second := <-got154+ if len(first) != 2 || first[0].Root != "root-a" || len(second) != 1 || second[0].Root != "root-b" {155+ t.Fatalf("batches=%v,%v", first, second)156+ }157+}158+159+func TestRunnerCancelFromKeepsOtherAuthorsQueued(t *testing.T) {160+ started := make(chan struct{})161+ release := make(chan struct{})162+ var startOnce sync.Once163+ r, err := New(Options{AllowedRooms: []string{"one"}, Handle: func(ctx context.Context, batch []Mention) error {164+ startOnce.Do(func() { close(started) })165+ select {166+ case <-release:167+ return nil168+ case <-ctx.Done():169+ return ctx.Err()170+ }171+ }})172+ if err != nil {173+ t.Fatal(err)174+ }175+ r.Start(context.Background())176+ defer r.Stop()177+ if err := r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "a", Root: "root"}); err != nil {178+ t.Fatal(err)179+ }180+ <-started181+ if err := r.Enqueue(Mention{Room: "one", Author: "bob", EventID: "b", Root: "root-b"}); err != nil {182+ t.Fatal(err)183+ }184+ if !r.CancelFrom("one", "alice") {185+ t.Fatal("alice cancellation was not accepted")186+ }187+ close(release)188+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)189+ defer cancel()190+ if err := r.Drain(ctx); err != nil {191+ t.Fatal(err)192+ }193+ if err := r.Enqueue(Mention{Room: "one", Author: "bob", EventID: "b"}); !errors.Is(err, ErrDuplicate) {194+ t.Fatalf("bob event should remain seen: %v", err)195+ }196+}
A internal/agentrunner/service.go +234 -0
@@ -0,0 +1,234 @@
1+package agentrunner2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "slices"9+ "strconv"10+ "strings"11+ "sync"12+ "time"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/google/uuid"16+)17+18+type ServiceOptions struct {19+ RelayURL, Secret, StatePath string20+ Rooms []string21+ Process ProcessOptions22+ Timeout time.Duration23+ Log func(error)24+}25+type service struct {26+ options ServiceOptions27+ relay RelayClient28+ queue *Runner29+ ctx context.Context30+ mu sync.Mutex31+ decisions map[string]chan event.Event32+}33+34+func Serve(ctx context.Context, opts ServiceOptions) error {35+ if opts.Timeout <= 0 {36+ opts.Timeout = 30 * time.Minute37+ }38+ pubkey, err := event.PublicKey(opts.Secret)39+ if err != nil {40+ return err41+ }42+ s := &service{options: opts, ctx: ctx, decisions: map[string]chan event.Event{}, relay: RelayClient{URL: opts.RelayURL, Secret: opts.Secret, PubKey: pubkey, Rooms: opts.Rooms, OnError: opts.Log}}43+ queue, err := New(Options{AllowedRooms: opts.Rooms, StatePath: opts.StatePath, Handle: s.handle, OnError: opts.Log})44+ if err != nil {45+ return err46+ }47+ s.queue = queue48+ s.relay.OnEvent = s.receive49+ // Start queues only after a connection is established, so journal recovery50+ // can publish status before it invokes the agent process.51+ s.relay.OnReady = func() { queue.Start(ctx) }52+ defer queue.Stop()53+ err = s.relay.Run(ctx)54+ if errors.Is(err, context.Canceled) {55+ return nil56+ }57+ return err58+}59+func (s *service) receive(e event.Event) {60+ s.mu.Lock()61+ for _, id := range event.TagValues(e, "e") {62+ if wait := s.decisions[id]; wait != nil {63+ select {64+ case wait <- e:65+ default:66+ }67+ }68+ }69+ s.mu.Unlock()70+ m, ok := s.relay.Mention(e)71+ if !ok {72+ return73+ }74+ if isCancelCommand(m.Prompt) {75+ s.queue.CancelFrom(m.Room, m.Author)76+ return77+ }78+ if err := s.queue.Enqueue(m); err != nil && !errors.Is(err, ErrDuplicate) && s.options.Log != nil {79+ s.options.Log(err)80+ }81+}82+func (s *service) handle(parent context.Context, batch []Mention) error {83+ ctx, cancel := context.WithTimeout(parent, s.options.Timeout)84+ defer cancel()85+ first := batch[0]86+ tags := [][]string{{"h", first.Room}, {"e", first.Root}, {"p", s.relay.PubKey}, {"output", "text/plain"}, {"subject", shortText(first.Prompt, 100)}, {"expiration", strconv.FormatInt(time.Now().Add(s.options.Timeout).Unix(), 10)}}87+ var prompt strings.Builder88+ prompt.WriteString("These are messages addressed to you in a Nostr chat. Reply to the users' request. Room: " + first.Room + "\n\n")89+ for _, m := range batch {90+ tags = append(tags, []string{"i", m.EventID, "event"})91+ fmt.Fprintf(&prompt, "Author: %s\nMessage: %s\n%s\n\n", m.Author, m.EventID, m.Prompt)92+ }93+ request, err := s.relay.Publish(ctx, event.Event{Kind: 5000, CreatedAt: first.CreatedAt, Tags: tags, Content: shortText(first.Prompt, 1000)})94+ if err != nil {95+ return err96+ }97+ feedback := func(status, info string) error {98+ _, err := s.relay.Publish(ctx, event.Event{Kind: 7000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"status", status, shortText(info, 500)}}, Content: shortText(info, 4000)})99+ return err100+ }101+ if err := feedback("processing", "Working on your request"); err != nil {102+ return err103+ }104+ process := s.options.Process105+ var last time.Time106+ process.Progress = func(info string) {107+ if time.Since(last) < 2*time.Second {108+ return109+ }110+ last = time.Now()111+ if err := feedback("processing", info); err != nil {112+ cancel()113+ }114+ }115+ process.Permission = func(ctx context.Context, p Permission) (string, error) { return s.permission(ctx, batch, p) }116+ heartbeatCtx, stopHeartbeat := context.WithCancel(ctx)117+ heartbeatDone := make(chan struct{})118+ go func() { defer close(heartbeatDone); s.heartbeat(heartbeatCtx, first.Room) }()119+ output, runErr := RunProcess(ctx, process, prompt.String())120+ stopHeartbeat()121+ <-heartbeatDone122+ finishCtx, finish := context.WithTimeout(s.ctx, 10*time.Second)123+ defer finish()124+ finalStatus := "idle"125+ if runErr != nil {126+ finalStatus = "offline"127+ _ = s.publishPresence(finishCtx, first.Room, finalStatus)128+ message := "Agent stopped: " + runErr.Error()129+ if errors.Is(runErr, context.Canceled) {130+ message = "Canceled"131+ }132+ _, publishErr := s.relay.Publish(finishCtx, event.Event{Kind: 7000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"status", "error"}}, Content: shortText(message, 1000)})133+ if publishErr != nil {134+ return errors.Join(runErr, publishErr)135+ }136+ return runErr137+ }138+ _ = s.publishPresence(finishCtx, first.Room, finalStatus)139+ if output == "" {140+ output = "Task completed."141+ }142+ raw, _ := json.Marshal(request)143+ _, err = s.relay.Publish(ctx, event.Event{Kind: 6000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"request", string(raw)}}, Content: output})144+ if err != nil {145+ return err146+ }147+ replyTags := [][]string{{"h", first.Room}, {"e", first.Root, "", "root"}}148+ for _, m := range batch {149+ if !slices.ContainsFunc(replyTags, func(tag []string) bool { return len(tag) > 1 && tag[0] == "p" && tag[1] == m.Author }) {150+ replyTags = append(replyTags, []string{"p", m.Author})151+ }152+ }153+ _, err = s.relay.Publish(ctx, event.Event{Kind: 9, Tags: replyTags, Content: output})154+ return err155+}156+157+func isCancelCommand(prompt string) bool {158+ fields := strings.Fields(prompt)159+ return len(fields) > 0 && (fields[0] == "/cancel" || fields[0] == "/stop")160+}161+162+func (s *service) publishPresence(ctx context.Context, room, status string) error {163+ _, err := s.relay.Publish(ctx, event.Event{Kind: 20001, Tags: [][]string{{"h", room}}, Content: status})164+ return err165+}166+func (s *service) heartbeat(ctx context.Context, room string) {167+ timer := time.NewTicker(30 * time.Second)168+ defer timer.Stop()169+ for {170+ _, err := s.relay.Publish(ctx, event.Event{Kind: 20001, Tags: [][]string{{"h", room}}, Content: "working"})171+ if err != nil {172+ return173+ }174+ select {175+ case <-ctx.Done():176+ return177+ case <-timer.C:178+ }179+ }180+}181+func (s *service) permission(ctx context.Context, batch []Mention, p Permission) (string, error) {182+ allow, deny := "", ""183+ for _, option := range p.Options {184+ if option.Kind == "allow_once" {185+ allow = option.ID186+ }187+ if option.Kind == "reject_once" {188+ deny = option.ID189+ }190+ }191+ if allow == "" {192+ return deny, nil193+ }194+ first := batch[0]195+ expires := time.Now().Add(10 * time.Minute).Unix()196+ if deadline, ok := ctx.Deadline(); ok && deadline.Unix() < expires {197+ expires = deadline.Unix()198+ }199+ request := event.Event{Kind: 9, CreatedAt: time.Now().Unix(), Tags: [][]string{{"h", first.Room}, {"e", first.Root, "", "root"}, {"request", "approve"}, {"d", uuid.NewString()}, {"expiration", strconv.FormatInt(expires, 10)}, {"subject", shortText(p.Title, 150)}, {"p", first.Author}}, Content: "Allow this tool action once?\n\n" + shortText(p.Title, 4000) + "\n\n" + p.Details}200+ if err := event.Sign(&request, s.options.Secret); err != nil {201+ return "", err202+ }203+ wait := make(chan event.Event, 1)204+ s.mu.Lock()205+ s.decisions[request.ID] = wait206+ s.mu.Unlock()207+ defer func() { s.mu.Lock(); delete(s.decisions, request.ID); s.mu.Unlock() }()208+ if _, err := s.relay.Publish(ctx, request); err != nil {209+ return "", err210+ }211+ for {212+ select {213+ case <-ctx.Done():214+ return "", ctx.Err()215+ case answer := <-wait:216+ if answer.PubKey != first.Author || answer.Kind != 7 {217+ continue218+ }219+ switch strings.TrimSpace(answer.Content) {220+ case "+", "":221+ return allow, nil222+ case "-":223+ return deny, nil224+ }225+ }226+ }227+}228+func shortText(value string, size int) string {229+ runes := []rune(value)230+ if len(runes) > size {231+ return string(runes[:size]) + "…"232+ }233+ return value234+}
A internal/community/agent_grant_requests.go +350 -0
@@ -0,0 +1,350 @@
1+package community2+3+// Agent grant requests are deliberately ordinary NIP-22 comments. The4+// request describes a narrow change to the current grant; the operator's5+// signed 30392 replacement remains the authority.6+7+import (8+ "context"9+ "database/sql"10+ "encoding/json"11+ "errors"12+ "fmt"13+ "io"14+ "reflect"15+ "slices"16+ "strings"17+ "unicode/utf8"18+19+ "github.com/FelineStateMachine/tinyrelay/internal/event"20+)21+22+const agentGrantReasonMax = 50023+24+type grantRequestReader interface {25+ QueryRowContext(context.Context, string, ...any) *sql.Row26+}27+28+func grantRequestOpen(ctx context.Context, db grantRequestReader, request event.Event, now int64) error {29+ if expires := event.Expiration(request); expires != 0 && expires <= now {30+ return errors.New("conflict: grant request has expired")31+ }32+ var denied bool33+ err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM events AS reaction34+ WHERE kind=7 AND pubkey=? AND trim(json_extract(raw,'$.content'))='-' AND EXISTS(35+ SELECT 1 FROM tags WHERE event_id=reaction.id AND name='e' AND value=?))`, event.Tag(request, "p"), request.ID).Scan(&denied)36+ if err != nil {37+ return err38+ }39+ if denied {40+ return errors.New("conflict: grant request was denied")41+ }42+ return nil43+}44+45+type AgentGrantChanges struct {46+ Kinds []int `json:"kinds,omitempty"`47+ Rooms []string `json:"rooms,omitempty"`48+ Repos []AgentRepo `json:"repos,omitempty"`49+ Sites []AgentSite `json:"sites,omitempty"`50+ Wiki *string `json:"wiki,omitempty"`51+ Jobs *string `json:"jobs,omitempty"`52+ Rate *int `json:"rate,omitempty"`53+}54+55+type AgentGrantRequest struct {56+ Base string `json:"base"`57+ Changes AgentGrantChanges `json:"changes"`58+}59+60+type AgentGrantReview struct {61+ RequestID string `json:"request_id"`62+ Base string `json:"base"`63+ Agent string `json:"agent"`64+ Operator string `json:"operator"`65+ Before AgentGrant `json:"before"`66+ After AgentGrant `json:"after"`67+ Unsigned event.Event `json:"unsigned"`68+}69+70+func IsAgentGrantRequest(e event.Event) bool {71+ return e.Kind == 1111 && event.Tag(e, "request") == "grant"72+}73+74+func decodeGrantRequest(e event.Event) (AgentGrantRequest, error) {75+ if !IsAgentGrantRequest(e) {76+ return AgentGrantRequest{}, errors.New("invalid: not an agent grant request")77+ }78+ if strings.TrimSpace(e.Content) == "" || len([]rune(e.Content)) > agentGrantReasonMax {79+ return AgentGrantRequest{}, fmt.Errorf("invalid: grant request rationale must be 1 to %d characters", agentGrantReasonMax)80+ }81+ if !utf8.ValidString(e.Content) {82+ return AgentGrantRequest{}, errors.New("invalid: grant request rationale is not valid UTF-8")83+ }84+ var req AgentGrantRequest85+ dec := json.NewDecoder(strings.NewReader(event.Tag(e, "grant")))86+ dec.DisallowUnknownFields()87+ if err := dec.Decode(&req); err != nil || req.Base == "" {88+ return AgentGrantRequest{}, errors.New("invalid: grant request needs a base and changes")89+ }90+ var extra any91+ if err := dec.Decode(&extra); err != io.EOF {92+ return AgentGrantRequest{}, errors.New("invalid: grant request JSON has trailing data")93+ }94+ allowed := map[string]bool{"request": true, "grant": true, "E": true, "K": true, "P": true, "e": true, "k": true, "p": true, "subject": true, "expiration": true}95+ seen := map[string]bool{}96+ for _, tag := range e.Tags {97+ if len(tag) == 0 || !allowed[tag[0]] {98+ return AgentGrantRequest{}, errors.New("invalid: grant request contains an unsupported tag")99+ }100+ if seen[tag[0]] {101+ return AgentGrantRequest{}, errors.New("invalid: grant request contains duplicate tags")102+ }103+ seen[tag[0]] = true104+ }105+ if req.Changes.Kinds == nil && req.Changes.Rooms == nil && req.Changes.Repos == nil && req.Changes.Sites == nil && req.Changes.Wiki == nil && req.Changes.Jobs == nil && req.Changes.Rate == nil {106+ return AgentGrantRequest{}, errors.New("invalid: grant request has no changes")107+ }108+ if err := validateGrantChanges(req.Changes); err != nil {109+ return AgentGrantRequest{}, err110+ }111+ return req, nil112+}113+114+func validateGrantChanges(changes AgentGrantChanges) error {115+ for _, room := range changes.Rooms {116+ if !ValidRoomID(room) {117+ return errors.New("invalid: grant request room")118+ }119+ }120+ repos, sites := map[string]bool{}, map[string]bool{}121+ for _, repo := range changes.Repos {122+ key := repo.Owner + ":" + repo.Identifier123+ if repos[key] {124+ return errors.New("invalid: repeated repository in grant request")125+ }126+ repos[key] = true127+ }128+ for _, site := range changes.Sites {129+ if sites[site.Label] || site.TTLDays < 0 {130+ return errors.New("invalid: duplicate site or negative TTL in grant request")131+ }132+ sites[site.Label] = true133+ }134+ return nil135+}136+137+func (s *Service) ReviewAgentGrantRequest(ctx context.Context, e event.Event, now int64) (AgentGrantReview, error) {138+ req, err := decodeGrantRequest(e)139+ if err != nil {140+ return AgentGrantReview{}, err141+ }142+ if err := grantRequestOpen(ctx, s.store.DB(), e, now); err != nil {143+ return AgentGrantReview{}, err144+ }145+ operator := event.Tag(e, "p")146+ if req.Base != event.Tag(e, "E") || req.Base != event.Tag(e, "e") {147+ return AgentGrantReview{}, errors.New("invalid: grant request base must match its NIP-22 references")148+ }149+ if len(event.TagValues(e, "p")) != 1 || operator == "" {150+ return AgentGrantReview{}, errors.New("invalid: grant request needs exactly one operator")151+ }152+ if len(event.Tag(e, "E")) != 64 || event.Tag(e, "e") != event.Tag(e, "E") || event.Tag(e, "K") != "30392" || event.Tag(e, "k") != "30392" || event.Tag(e, "P") != operator || event.Tag(e, "p") != operator {153+ return AgentGrantReview{}, errors.New("invalid: grant request needs matching NIP-22 grant references")154+ }155+ var raw string156+ var currentID string157+ var paused int158+ var revokedAt int64159+ if err := s.store.DB().QueryRowContext(ctx, `SELECT event_id,paused,revoked_at FROM agent_grants WHERE agent=?`, e.PubKey).Scan(&currentID, &paused, &revokedAt); err != nil || currentID != req.Base {160+ return AgentGrantReview{}, errors.New("conflict: grant request is no longer for the current grant")161+ }162+ if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, req.Base, event.KIND_AGENT_GRANT).Scan(&raw); err != nil {163+ if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, req.Base).Scan(&raw); err != nil {164+ return AgentGrantReview{}, errors.New("invalid: current agent grant not found")165+ }166+ }167+ var beforeEvent event.Event168+ if beforeEvent, err = event.Parse([]byte(raw)); err != nil {169+ return AgentGrantReview{}, err170+ }171+ review, err := s.reviewAgainstGrant(e, req, beforeEvent, now)172+ if err != nil {173+ return AgentGrantReview{}, err174+ }175+ review.Before.Paused = paused != 0176+ review.Before.RevokedAt = revokedAt177+ if !review.Before.Active(now) {178+ return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant")179+ }180+ return review, nil181+}182+183+func (s *Service) reviewAgainstGrant(e event.Event, req AgentGrantRequest, beforeEvent event.Event, now int64) (AgentGrantReview, error) {184+ operator := event.Tag(e, "p")185+ before, err := ParseAgentGrant(beforeEvent, now)186+ if err != nil {187+ return AgentGrantReview{}, err188+ }189+ if before.Agent != e.PubKey || before.Owner != operator || !before.Active(now) {190+ return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant")191+ }192+ afterEvent := beforeEvent193+ afterEvent.Tags = grantRequestTags(beforeEvent.Tags, req.Changes)194+ afterEvent.Tags = append(afterEvent.Tags, []string{"grant-request", e.ID}, []string{"grant-base", req.Base}, []string{"e", e.ID, "", "grant-request"})195+ afterEvent.ID, afterEvent.Sig, afterEvent.CreatedAt = "", "", now196+ after, err := ParseAgentGrant(afterEvent, now)197+ if err != nil {198+ return AgentGrantReview{}, err199+ }200+ if reflect.DeepEqual(after.Scope, before.Scope) {201+ return AgentGrantReview{}, errors.New("invalid: grant request changes nothing")202+ }203+ return AgentGrantReview{RequestID: e.ID, Base: req.Base, Agent: before.Agent, Operator: operator, Before: before, After: after, Unsigned: afterEvent}, nil204+}205+206+func (s *Service) ValidateAgentGrantReplacement(ctx context.Context, e event.Event, now int64) error {207+ return s.validateGrantReplacement(ctx, s.store.DB(), e, now)208+}209+210+func (s *Service) validateGrantReplacement(ctx context.Context, tx grantRequestReader, e event.Event, now int64) error {211+ requestID := event.Tag(e, "grant-request")212+ baseID := event.Tag(e, "grant-base")213+ if requestID == "" || baseID == "" || !slices.Contains(event.TagValues(e, "e"), requestID) {214+ return errors.New("invalid: grant replacement needs grant request provenance")215+ }216+ var raw string217+ if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=1111`, requestID).Scan(&raw); err != nil {218+ return errors.New("invalid: grant request is not stored")219+ }220+ request, err := event.Parse([]byte(raw))221+ if err != nil {222+ return err223+ }224+ if err := grantRequestOpen(ctx, tx, request, now); err != nil {225+ return err226+ }227+ var grantRaw string228+ if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, baseID, event.KIND_AGENT_GRANT).Scan(&grantRaw); err != nil {229+ if err := tx.QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, baseID).Scan(&grantRaw); err != nil {230+ return errors.New("invalid: current agent grant not found")231+ }232+ }233+ grantEvent, err := event.Parse([]byte(grantRaw))234+ if err != nil {235+ return err236+ }237+ req, err := decodeGrantRequest(request)238+ if err != nil {239+ return err240+ }241+ review, err := s.reviewAgainstGrant(request, req, grantEvent, now)242+ if err != nil || review.Base != baseID || review.Operator != e.PubKey || review.Agent != event.Tag(e, "d") {243+ return errors.New("restricted: grant replacement does not match the reviewed request")244+ }245+ var paused int246+ var revokedAt int64247+ if err := tx.QueryRowContext(ctx, `SELECT paused,revoked_at FROM agent_grants WHERE agent=?`, review.Agent).Scan(&paused, &revokedAt); err != nil {248+ return errors.New("conflict: agent grant is no longer current")249+ }250+ if paused != 0 || revokedAt != 0 || review.After.ExpiresAt <= now {251+ return errors.New("restricted: grant request is no longer active")252+ }253+ got, err := ParseAgentGrant(e, now)254+ if err != nil {255+ return err256+ }257+ if !reflect.DeepEqual(got.Scope, review.After.Scope) || got.ExpiresAt != review.After.ExpiresAt || got.Agent != review.After.Agent || got.Owner != review.After.Owner || got.Name != review.After.Name || e.Content != grantEvent.Content {258+ return errors.New("invalid: grant replacement scope differs from the reviewed request")259+ }260+ if !reflect.DeepEqual(e.Tags, review.Unsigned.Tags) {261+ return errors.New("invalid: grant replacement tags differ from the reviewed request")262+ }263+ var current string264+ if err := tx.QueryRowContext(ctx, `SELECT event_id FROM agent_grants WHERE agent=?`, got.Agent).Scan(&current); err != nil || current != baseID {265+ return errors.New("conflict: agent grant changed since the request")266+ }267+ return nil268+}269+270+func grantRequestTags(tags [][]string, changes AgentGrantChanges) [][]string {271+ replaceRepo := map[string]bool{}272+ for _, repo := range changes.Repos {273+ replaceRepo[repo.Owner+":"+repo.Identifier] = true274+ }275+ replaceSite := map[string]bool{}276+ for _, site := range changes.Sites {277+ replaceSite[site.Label] = true278+ }279+ copyTags := make([][]string, 0, len(tags)+len(changes.Kinds)+len(changes.Rooms))280+ seenKinds := map[string]bool{}281+ seenRooms := map[string]bool{}282+ remove := map[string]bool{}283+ if changes.Wiki != nil {284+ remove["wiki"] = true285+ }286+ if changes.Jobs != nil {287+ remove["jobs"] = true288+ }289+ if changes.Rate != nil {290+ remove["rate"] = true291+ }292+ for _, tag := range tags {293+ skip := false294+ if len(tag) > 1 && tag[0] == "repo" {295+ parts := strings.SplitN(tag[1], ":", 3)296+ skip = len(parts) == 3 && replaceRepo[parts[0]+":"+parts[1]]297+ }298+ if len(tag) > 1 && tag[0] == "sites" {299+ skip = replaceSite[tag[1]]300+ }301+ if len(tag) > 0 && !remove[tag[0]] && !skip && tag[0] != "grant-request" && tag[0] != "grant-base" && !(tag[0] == "e" && len(tag) > 3 && tag[3] == "grant-request") {302+ copyTags = append(copyTags, append([]string(nil), tag...))303+ if len(tag) > 1 && tag[0] == "k" {304+ seenKinds[tag[1]] = true305+ }306+ if len(tag) > 1 && tag[0] == "room" {307+ seenRooms[tag[1]] = true308+ }309+ }310+ }311+ // Changes are patches: retain the old entries and add or replace only the312+ // requested keys. Reconstructing these fields also gives deterministic313+ // output when a request repeats an existing value.314+ for _, k := range changes.Kinds {315+ value := fmt.Sprint(k)316+ if !seenKinds[value] {317+ copyTags = append(copyTags, []string{"k", value})318+ seenKinds[value] = true319+ }320+ }321+ for _, room := range changes.Rooms {322+ if !seenRooms[room] {323+ copyTags = append(copyTags, []string{"room", room})324+ seenRooms[room] = true325+ }326+ }327+ for _, repo := range changes.Repos {328+ copyTags = append(copyTags, []string{"repo", repo.Owner + ":" + repo.Identifier + ":" + repo.Level})329+ }330+ for _, site := range changes.Sites {331+ tag := []string{"sites", site.Label}332+ if site.TTLDays > 0 {333+ tag = append(tag, fmt.Sprintf("ttl=%d", site.TTLDays))334+ }335+ if site.Encrypted {336+ tag = append(tag, "encrypted")337+ }338+ copyTags = append(copyTags, tag)339+ }340+ if changes.Wiki != nil {341+ copyTags = append(copyTags, []string{"wiki", *changes.Wiki})342+ }343+ if changes.Jobs != nil {344+ copyTags = append(copyTags, []string{"jobs", *changes.Jobs})345+ }346+ if changes.Rate != nil {347+ copyTags = append(copyTags, []string{"rate", fmt.Sprint(*changes.Rate)})348+ }349+ return copyTags350+}
A internal/community/agent_grant_requests_test.go +34 -0
@@ -0,0 +1,34 @@
1+package community2+3+import (4+ "reflect"5+ "testing"6+)7+8+func TestGrantRequestTagsPatchOnlyRequestedEntries(t *testing.T) {9+ old := [][]string{10+ {"d", "agent"}, {"p", "agent"}, {"expiration", "200"},11+ {"k", "1"}, {"k", "9"}, {"room", "alpha"}, {"room", "beta"},12+ {"repo", "owner:one:read"}, {"repo", "owner:two:maintain"},13+ {"grant-request", "old"}, {"grant-base", "old"}, {"e", "old", "", "grant-request"},14+ }15+ want := [][]string{16+ {"d", "agent"}, {"p", "agent"}, {"expiration", "200"},17+ {"k", "1"}, {"k", "9"}, {"room", "alpha"}, {"room", "beta"},18+ {"repo", "owner:one:read"}, {"repo", "owner:two:maintain"},19+ {"k", "11"},20+ }21+ got := grantRequestTags(old, AgentGrantChanges{Kinds: []int{11}, Rooms: []string{"alpha"}})22+ if !reflect.DeepEqual(got, want) {23+ t.Fatalf("patch tags = %#v, want %#v", got, want)24+ }25+}26+27+func TestGrantRequestPatchKeepsOtherRepositoriesAndSites(t *testing.T) {28+ old := [][]string{{"repo", "owner:one:read"}, {"repo", "owner:two:maintain"}, {"sites", "one", "encrypted"}, {"sites", "two", "ttl=7"}}29+ changed := grantRequestTags(old, AgentGrantChanges{Repos: []AgentRepo{{Owner: "owner", Identifier: "one", Level: "maintain"}}, Sites: []AgentSite{{Label: "one", TTLDays: 3}}})30+ want := [][]string{{"repo", "owner:two:maintain"}, {"sites", "two", "ttl=7"}, {"repo", "owner:one:maintain"}, {"sites", "one", "ttl=3"}}31+ if !reflect.DeepEqual(changed, want) {32+ t.Fatalf("patch changed unrelated entries: %v", changed)33+ }34+}
M internal/community/agents.go +5 -0
M internal/daemon/approvals.go +37 -17
@@ -13,6 +13,7 @@ import (
1313 "strings"1414 "time"1515 16+ "github.com/FelineStateMachine/tinyrelay/internal/community"1617 "github.com/FelineStateMachine/tinyrelay/internal/event"1718 "github.com/FelineStateMachine/tinyrelay/internal/storage"1819 )
@@ -26,8 +27,8 @@ const (
2627 )2728 2829 // approvalTypes is the bounded vocabulary of the request tag.29-var approvalTypes = []string{"approve", "decide", "question"}30-var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot}30+var approvalTypes = []string{"approve", "decide", "question", "grant"}31+var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot, event.KIND_THREAD_REPLY}3132 3233 const (3334 approvalWikiProposal = "wiki-proposal"
@@ -36,20 +37,22 @@ const (
3637 3738 // approvalItem is one request as the asked person sees it.3839 type approvalItem struct {39- ID string `json:"id"`40- Kind int `json:"kind"`41- Type string `json:"type"`42- Asker string `json:"asker"`43- Asked []string `json:"asked"`44- Subject string `json:"subject,omitempty"`45- Content string `json:"content"`46- CreatedAt int64 `json:"created_at"`47- Expires int64 `json:"expires,omitempty"`48- State string `json:"state"`49- Room string `json:"room,omitempty"`50- About *approvalAbout `json:"about,omitempty"`51- Answer *approvalAnswer `json:"answer,omitempty"`52- Event event.Event `json:"event"`40+ ID string `json:"id"`41+ Kind int `json:"kind"`42+ Type string `json:"type"`43+ Asker string `json:"asker"`44+ Asked []string `json:"asked"`45+ Subject string `json:"subject,omitempty"`46+ Content string `json:"content"`47+ CreatedAt int64 `json:"created_at"`48+ Expires int64 `json:"expires,omitempty"`49+ State string `json:"state"`50+ Room string `json:"room,omitempty"`51+ About *approvalAbout `json:"about,omitempty"`52+ Answer *approvalAnswer `json:"answer,omitempty"`53+ Event event.Event `json:"event"`54+ Grant *community.AgentGrantReview `json:"grant,omitempty"`55+ GrantError string `json:"grant_error,omitempty"`5356 }5457 5558 // approvalAbout is what the request refers to: a repository coordinate, an
@@ -82,6 +85,9 @@ func approvalRequest(e event.Event) (string, bool) {
8285 if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {8386 return "", false8487 }88+ if kind == "grant" && !community.IsAgentGrantRequest(e) {89+ return "", false90+ }8591 return kind, true8692 }8793
@@ -166,6 +172,11 @@ func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now
166172 if err != nil {167173 return nil, err168174 }175+ grants, err := t.grantApprovalHistory(ctx, items)176+ if err != nil {177+ return nil, err178+ }179+ rows.Events = append(rows.Events, grants...)169180 sortEventsNewestFirst(rows.Events)170181 for _, row := range rows.Events {171182 if _, isRequest := approvalRequest(row); isRequest {
@@ -183,6 +194,10 @@ func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now
183194 // approvalSettle fills the answer and state of one request from its answers,184195 // which arrive newest first.185196 func approvalSettle(item *approvalItem, rows []event.Event, now int64) {197+ if item.Type == "grant" {198+ settleGrantApproval(item, rows, now)199+ return200+ }186201 var reply *event.Event187202 for i := range rows {188203 row := rows[i]
@@ -192,7 +207,7 @@ func approvalSettle(item *approvalItem, rows []event.Event, now int64) {
192207 item.State = "answered"193208 return194209 }195- } else if reply == nil {210+ } else if row.Kind == kindComment && reply == nil {196211 reply = &row197212 }198213 }
@@ -230,6 +245,7 @@ func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storag
230245 }231246 for i := range items {232247 approvalSettle(&items[i], answers[items[i].ID], now)248+ t.reviewGrantApproval(ctx, &items[i], now)233249 }234250 wikiItems, wikiMore, err := t.wikiApprovalItems(ctx, pubkey, before, limit)235251 if err != nil {
@@ -288,6 +304,10 @@ func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {
288304 if kind != "question" {289305 actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}}290306 }307+ if kind == "grant" {308+ body = shortKey(e.PubKey) + " requests an access change: " + excerpt(e.Content)309+ actions = []pushAction{{Action: "review", Title: "Review access"}}310+ }291311 var notices []pushNotice292312 for _, recipient := range approvalAsked(e) {293313 notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions})
M internal/daemon/approvals_browse.go +1 -0
A internal/daemon/chat_activity.go +123 -0
@@ -0,0 +1,123 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "time"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/storage"11+)12+13+func (t *Tenant) browseChatActivity(ctx context.Context, actor string, params []json.RawMessage) (any, error) {14+ var q struct {15+ Room string `json:"room"`16+ Root string `json:"root"`17+ }18+ if len(params) != 1 || json.Unmarshal(params[0], &q) != nil || q.Room == "" {19+ return nil, errors.New("invalid: chat activity room")20+ }21+ if _, _, err := t.roomFor(ctx, actor, q.Room); err != nil {22+ return nil, err23+ }24+ if q.Root != "" {25+ root, err := t.roomThreadRoot(ctx, actor, q.Room, q.Root)26+ if err != nil {27+ return nil, err28+ }29+ q.Root = root.ID30+ }31+ rows := []event.Event{}32+ for _, filter := range []event.Filter{33+ {Kinds: approvalKinds, Tags: map[string][]string{"h": {q.Room}}},34+ {Kinds: jobRequestKinds, Tags: map[string][]string{"h": {q.Room}}},35+ } {36+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Access: storage.Access{PubKeys: []string{actor}}, Limit: 1000})37+ if err != nil {38+ return nil, err39+ }40+ rows = append(rows, page.Events...)41+ }42+ session := browseSession(t, actor)43+ jobs, approvals := []jobItem{}, []approvalItem{}44+ now := time.Now().Unix()45+ roots := map[string]string{}46+ for _, row := range rows {47+ _, isApproval := approvalRequest(row)48+ if !event.IsJobRequest(row.Kind) && !isApproval {49+ continue50+ }51+ if !t.gate.CanSee(ctx, row, session, nil) {52+ continue53+ }54+ if q.Root != "" && !t.activityInThread(ctx, actor, q.Root, row, roots) {55+ continue56+ }57+ if event.IsJobRequest(row.Kind) && t.Policy().Features.Jobs && (event.Expiration(row) == 0 || event.Expiration(row) > now) {58+ jobs = append(jobs, jobItemFrom(row))59+ } else if kind, ok := approvalRequest(row); ok && (row.PubKey == actor || containsString(approvalAsked(row), actor)) {60+ approvals = append(approvals, approvalItemFrom(row, kind, t.publicURL))61+ }62+ }63+ // Keep the inline panel bounded even when a busy room has accumulated a64+ // large amount of historical task and approval traffic. The store returns65+ // each filter newest first, so retaining the first 50 preserves the useful66+ // recent context while the dedicated views remain available for history.67+ if len(jobs) > 50 {68+ jobs = jobs[:50]69+ }70+ if len(approvals) > 50 {71+ approvals = approvals[:50]72+ }73+ answers, err := t.jobAnswers(ctx, jobs, session, now)74+ if err != nil {75+ return nil, err76+ }77+ for i := range jobs {78+ jobSettle(&jobs[i], answers[jobs[i].ID])79+ }80+ decisions, err := t.approvalAnswers(ctx, approvals, now)81+ if err != nil {82+ return nil, err83+ }84+ for i := range approvals {85+ approvalSettle(&approvals[i], decisions[approvals[i].ID], now)86+ }87+ return map[string]any{"jobs": jobs, "approvals": approvals}, nil88+}89+90+func (b backend) ReadChatActivity(ctx context.Context, actor, room, root string) (any, error) {91+ raw, err := json.Marshal(map[string]string{"room": room, "root": root})92+ if err != nil {93+ return nil, err94+ }95+ return b.tenant.Execute(ctx, actor, "browsechatactivity", []json.RawMessage{raw})96+}97+98+// Resolve parent-only references once per parent while keeping unrelated tasks99+// out of the thread. roomThreadRoot also enforces room and reader access.100+func (t *Tenant) activityInThread(ctx context.Context, actor, root string, row event.Event, roots map[string]string) bool {101+ if row.ID == root {102+ return true103+ }104+ reference := event.RoomReplyRoot(row)105+ if reference == "" && event.IsJobRequest(row.Kind) {106+ reference = event.Tag(row, "e")107+ }108+ if reference == root {109+ return true110+ }111+ if reference == "" {112+ return false113+ }114+ canonical, known := roots[reference]115+ if !known {116+ parent, err := t.roomThreadRoot(ctx, actor, event.Tag(row, "h"), reference)117+ if err == nil {118+ canonical = parent.ID119+ }120+ roots[reference] = canonical121+ }122+ return canonical == root123+}
A internal/daemon/chat_activity_test.go +88 -0
@@ -0,0 +1,88 @@
1+package daemon2+3+import (4+ "encoding/json"5+ "github.com/FelineStateMachine/tinyrelay/internal/event"6+ "testing"7+)8+9+func TestChatActivitySharesApprovalStateAndScopesRooms(t *testing.T) {10+ h := newRoomHarness(t)11+ request := h.must("alice", 5000, [][]string{{"h", "main"}, {"p", h.keys["bob"]}, {"subject", "Build preview"}}, "Build preview")12+ h.must("bob", 7000, [][]string{{"h", "main"}, {"e", request.ID}, {"p", h.keys["alice"]}, {"status", "processing", "Running checks"}}, "2 of 3 checks complete")13+ decision := h.must("alice", 9, [][]string{{"h", "main"}, {"request", "approve"}, {"p", h.keys["bob"]}, {"subject", "Publish preview"}}, "Publish it?")14+ read := func(who, room string) map[string]any {15+ t.Helper()16+ raw, err := h.tenant.Execute(h.ctx, h.keys[who], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": room})})17+ if err != nil {18+ t.Fatal(err)19+ }20+ return raw.(map[string]any)21+ }22+ data := read("bob", "main")23+ jobs := data["jobs"].([]jobItem)24+ if len(jobs) != 1 || jobs[0].Status != "processing" {25+ t.Fatalf("jobs=%v", jobs)26+ }27+ approvals := data["approvals"].([]approvalItem)28+ if len(approvals) != 1 || approvals[0].State != "open" {29+ t.Fatalf("approvals=%v", approvals)30+ }31+ h.must("bob", 7, [][]string{{"h", "main"}, {"e", decision.ID}, {"p", h.keys["alice"]}}, "+")32+ data = read("bob", "main")33+ if data["approvals"].([]approvalItem)[0].State != "answered" {34+ t.Fatal("inline approval not settled")35+ }36+ detail, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browseapproval", []json.RawMessage{rawJSON(map[string]string{"id": decision.ID})})37+ if err != nil {38+ t.Fatal(err)39+ }40+ if detail.(map[string]any)["item"].(approvalItem).State != "answered" {41+ t.Fatal("Approvals disagrees")42+ }43+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-chat"}, {"name", "Private"}, {"visibility", "members"}}, "")44+ private := h.must("alice", 5000, [][]string{{"h", "private-chat"}, {"p", h.keys["alice"]}}, "Private work")45+ if _, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": "private-chat"})}); err == nil {46+ t.Fatal("outsider read private activity")47+ }48+ for _, job := range read("bob", "main")["jobs"].([]jobItem) {49+ if job.ID == private.ID {50+ t.Fatal("private job leaked")51+ }52+ }53+ if _, err := h.publish("alice", 7000, [][]string{{"e", private.ID}, {"p", h.keys["alice"]}, {"status", "processing"}}, "private progress"); err == nil {54+ t.Fatal("unscoped progress accepted for a private room task")55+ }56+}57+func TestChatPresenceIsEphemeralAndRoomAuthorized(t *testing.T) {58+ h := newRoomHarness(t)59+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-chat"}, {"visibility", "members"}}, "")60+ for _, kind := range []int{20001, 20002} {61+ e := h.must("alice", kind, [][]string{{"h", "private-chat"}}, "typing")62+ if rows := h.query("alice", event.Filter{IDs: []string{e.ID}}); len(rows) != 0 {63+ t.Fatal("presence persisted")64+ }65+ if _, err := h.publish("bob", kind, [][]string{{"h", "private-chat"}}, "typing"); err == nil {66+ t.Fatal("outsider presence accepted")67+ }68+ }69+}70+71+func TestChatActivityIncludesParentOnlyRequestsInCanonicalThread(t *testing.T) {72+ h := newRoomHarness(t)73+ root := h.must("alice", 9, [][]string{{"h", "main"}}, "Root")74+ parent := h.must("bob", 9, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, "Parent")75+ approval := h.must("alice", 9, [][]string{{"h", "main"}, {"e", parent.ID, "", "reply"}, {"request", "approve"}, {"p", h.keys["bob"]}}, "Approve the nested action?")76+ job := h.must("alice", 5000, [][]string{{"h", "main"}, {"e", parent.ID}, {"p", h.keys["bob"]}}, "Nested job")77+ other := h.must("alice", 9, [][]string{{"h", "main"}}, "Another root")78+ h.must("alice", 9, [][]string{{"h", "main"}, {"e", other.ID, "", "root"}, {"request", "approve"}, {"p", h.keys["bob"]}}, "Unrelated action")79+ raw, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": "main", "root": root.ID})})80+ if err != nil {81+ t.Fatal(err)82+ }83+ data := raw.(map[string]any)84+ approvals, jobs := data["approvals"].([]approvalItem), data["jobs"].([]jobItem)85+ if len(approvals) != 1 || approvals[0].ID != approval.ID || len(jobs) != 1 || jobs[0].ID != job.ID {86+ t.Fatalf("activity omitted nested request or included another thread: %v", data)87+ }88+}
A internal/daemon/chat_preferences.go +74 -0
@@ -0,0 +1,74 @@
1+package daemon2+3+import (4+ "context"5+ "database/sql"6+ "encoding/json"7+ "errors"8+ "io"9+ "net/http"10+ "strings"11+)12+13+const chatSharePresenceSetting = "account.chat.share_presence."14+15+type chatPreferences struct {16+ SharePresence bool `json:"share_presence"`17+}18+19+func chatPreferenceKey(actor string) string {20+ return chatSharePresenceSetting + actor21+}22+23+func (t *Tenant) chatPreferencesHTTP(w http.ResponseWriter, r *http.Request) {24+ w.Header().Set("Cache-Control", "private, no-store")25+ r.Body = http.MaxBytesReader(w, r.Body, 4096)26+ actor, err := t.resolveUIActor(r)27+ if err != nil || actor == "" {28+ http.Error(w, "authentication required", http.StatusUnauthorized)29+ return30+ }31+ key := chatPreferenceKey(actor)32+ if r.Method == http.MethodGet || r.Method == http.MethodHead {33+ preferences := chatPreferences{}34+ if err := t.store.GetSetting(r.Context(), key, &preferences); err != nil && !errors.Is(err, sql.ErrNoRows) {35+ http.Error(w, "could not read chat preferences", http.StatusInternalServerError)36+ return37+ }38+ writeJSON(w, http.StatusOK, preferences)39+ return40+ }41+ if r.Method != http.MethodPost {42+ w.Header().Set("Allow", "GET, HEAD, POST")43+ w.WriteHeader(http.StatusMethodNotAllowed)44+ return45+ }46+ var preferences chatPreferences47+ decoder := json.NewDecoder(r.Body)48+ decoder.DisallowUnknownFields()49+ if err := decoder.Decode(&preferences); err != nil {50+ http.Error(w, "invalid chat preferences", http.StatusBadRequest)51+ return52+ }53+ var extra any54+ if err := decoder.Decode(&extra); err != io.EOF {55+ http.Error(w, "invalid chat preferences", http.StatusBadRequest)56+ return57+ }58+ if err := t.store.PutSetting(r.Context(), key, preferences); err != nil {59+ http.Error(w, "could not save chat preferences", http.StatusInternalServerError)60+ return61+ }62+ writeJSON(w, http.StatusOK, preferences)63+}64+65+func (t *Tenant) chatSharePresence(ctx context.Context, actor string) bool {66+ if strings.TrimSpace(actor) == "" {67+ return false68+ }69+ var preferences chatPreferences70+ if err := t.store.GetSetting(ctx, chatPreferenceKey(actor), &preferences); err != nil {71+ return false72+ }73+ return preferences.SharePresence74+}
A internal/daemon/chat_preferences_test.go +59 -0
@@ -0,0 +1,59 @@
1+package daemon2+3+import (4+ "context"5+ "io"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+)11+12+func TestChatPreferencesWritesBindAccountAndSignedBody(t *testing.T) {13+ app, tenant := testTenant(t)14+ owner := tenant.Policy().Owner15+ if err := tenant.store.PutSetting(context.Background(), chatPreferenceKey(owner), chatPreferences{SharePresence: true}); err != nil {16+ t.Fatal(err)17+ }18+ other := strings.Repeat("0", 63) + "2"19+ read := httptest.NewRequest("GET", "http://relay.test/chat/preferences?actor="+owner, nil)20+ signRequestWithSecret(t, read, "", other)21+ result := httptest.NewRecorder()22+ app.ServeHTTP(result, read)23+ if result.Code != 200 || result.Body.String() != `{"share_presence":false}` {24+ t.Fatalf("cross-account read: %d %s", result.Code, result.Body.String())25+ }26+ for _, test := range []struct {27+ name, body string28+ signed bool29+ code int30+ }{31+ {"unsigned", `{"share_presence":false}`, false, 401},32+ {"actor injection", `{"actor":"` + owner + `","share_presence":false}`, true, 400},33+ {"trailing object", `{"share_presence":false}{}`, true, 400},34+ {"invalid type", `{"share_presence":"true"}`, true, 400},35+ } {36+ t.Run(test.name, func(t *testing.T) {37+ req := httptest.NewRequest("POST", "http://relay.test/chat/preferences", strings.NewReader(test.body))38+ if test.signed {39+ signRequestWithSecret(t, req, test.body, other)40+ }41+ res := httptest.NewRecorder()42+ app.ServeHTTP(res, req)43+ if res.Code != test.code {44+ t.Fatalf("status %d: %s", res.Code, res.Body.String())45+ }46+ })47+ }48+ altered := httptest.NewRequest("POST", "http://relay.test/chat/preferences", strings.NewReader(`{"share_presence":true}`))49+ signRequest(t, altered, `{"share_presence":true}`)50+ altered.Body = io.NopCloser(strings.NewReader(`{"share_presence":false}`))51+ rejected := httptest.NewRecorder()52+ app.ServeHTTP(rejected, altered)53+ if rejected.Code != http.StatusUnauthorized {54+ t.Fatalf("altered signed body: %d", rejected.Code)55+ }56+ if !tenant.chatSharePresence(context.Background(), owner) {57+ t.Fatal("another account or invalid signature changed owner preference")58+ }59+}
M internal/daemon/client_browse.go +4 -1
A internal/daemon/grant_admission_test.go +194 -0
@@ -0,0 +1,194 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strconv"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/community"12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+)14+15+func grantRequestEvent(t *testing.T, secret string, grant event.Event, at int64, content, grantJSON string, tags ...[]string) event.Event {16+ t.Helper()17+ base := [][]string{{"request", "grant"}, {"grant", grantJSON}, {"E", grant.ID, "", grant.PubKey}, {"K", "30392"}, {"P", grant.PubKey}, {"e", grant.ID, "", grant.PubKey}, {"k", "30392"}, {"p", grant.PubKey}}18+ return signedEvent(t, secret, 1111, at, append(base, tags...), content)19+}20+21+func grantRequestJSON(t *testing.T, grant event.Event, changes community.AgentGrantChanges) string {22+ t.Helper()23+ raw, err := json.Marshal(community.AgentGrantRequest{Base: grant.ID, Changes: changes})24+ if err != nil {25+ t.Fatal(err)26+ }27+ return string(raw)28+}29+30+func setGrantTag(t *testing.T, e *event.Event, name, value string) {31+ t.Helper()32+ for _, tag := range e.Tags {33+ if len(tag) > 1 && tag[0] == name {34+ tag[1] = value35+ return36+ }37+ }38+ t.Fatalf("grant replacement has no %s tag", name)39+}40+41+func TestGrantRequestAdmissionRequiresAgentAndValidCurrentGrant(t *testing.T) {42+ _, tenant := testTenant(t)43+ now := time.Now().Unix()44+ agent, _ := event.PublicKey(testAgentSecret)45+ member, _ := event.PublicKey(testMemberSecret)46+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}, []string{"k", "1111"})47+ if err := publishAs(t, tenant, grant); err != nil {48+ t.Fatal(err)49+ }50+ reqJSON := grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}})51+ human := grantRequestEvent(t, testMemberSecret, grant, now, "please", reqJSON)52+ if err := publishAs(t, tenant, human); err == nil {53+ t.Fatal("human request accepted")54+ }55+56+ for name, mutate := range map[string]func(event.Event) event.Event{57+ "rationale over limit": func(e event.Event) event.Event { e.Content = strings.Repeat("x", 501); return e },58+ "rationale whitespace": func(e event.Event) event.Event { e.Content = " \t\n "; return e },59+ "duplicate operator": func(e event.Event) event.Event { e.Tags = append(e.Tags, []string{"p", grant.PubKey}); return e },60+ "wrong operator": func(e event.Event) event.Event { e.Tags[4][1] = member; e.Tags[7][1] = member; return e },61+ "wrong base": func(e event.Event) event.Event {62+ e.Tags[2][1] = strings.Repeat("f", 64)63+ e.Tags[5][1] = strings.Repeat("f", 64)64+ return e65+ },66+ "unknown change": func(e event.Event) event.Event {67+ e.Tags[1][1] = `{"base":"` + grant.ID + `","changes":{"nope":true}}`68+ return e69+ },70+ "noop": func(e event.Event) event.Event {71+ e.Tags[1][1] = grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{9}})72+ return e73+ },74+ } {75+ t.Run(name, func(t *testing.T) {76+ e := grantRequestEvent(t, testAgentSecret, grant, now+1, "please", reqJSON)77+ e = mutate(e)78+ if err := event.Sign(&e, testAgentSecret); err != nil {79+ t.Fatal(err)80+ }81+ if err := publishAs(t, tenant, e); err == nil {82+ t.Fatalf("malformed request accepted: %v", e)83+ }84+ })85+ }86+}87+88+func TestGrantRequestAdmissionRejectsPausedAndRevokedAgents(t *testing.T) {89+ for _, action := range []string{"pauseagent", "revokeagent"} {90+ t.Run(action, func(t *testing.T) {91+ _, tenant := testTenant(t)92+ now := time.Now().Unix()93+ agent, _ := event.PublicKey(testAgentSecret)94+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"})95+ if err := publishAs(t, tenant, grant); err != nil {96+ t.Fatal(err)97+ }98+ args := []json.RawMessage{json.RawMessage(`"` + agent + `"`)}99+ if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, action, args); err != nil {100+ t.Fatal(err)101+ }102+ req := grantRequestEvent(t, testAgentSecret, grant, now+1, "please", grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}}))103+ if err := publishAs(t, tenant, req); err == nil {104+ t.Fatalf("request accepted after %s", action)105+ }106+ })107+ }108+}109+110+func TestGrantRequestReplacementMustMatchPreparedReview(t *testing.T) {111+ mutations := map[string]func(*event.Event){112+ "expiry": func(e *event.Event) { setGrantTag(t, e, "expiration", "9999999999") },113+ "name": func(e *event.Event) { setGrantTag(t, e, "name", "changed") },114+ "content": func(e *event.Event) { e.Content = "changed" },115+ "tags": func(e *event.Event) { e.Tags = append(e.Tags, []string{"note", "changed"}) },116+ }117+ for name, mutate := range mutations {118+ t.Run(name, func(t *testing.T) {119+ _, tenant := testTenant(t)120+ now := time.Now().Unix()121+ agent, _ := event.PublicKey(testAgentSecret)122+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"})123+ if err := publishAs(t, tenant, grant); err != nil {124+ t.Fatal(err)125+ }126+ req := grantRequestEvent(t, testAgentSecret, grant, now, "please", grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}}))127+ if err := publishAs(t, tenant, req); err != nil {128+ t.Fatal(err)129+ }130+ review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), req, now)131+ if err != nil {132+ t.Fatal(err)133+ }134+ replacement := review.Unsigned135+ mutate(&replacement)136+ if err := event.Sign(&replacement, testOwnerSecret); err != nil {137+ t.Fatal(err)138+ }139+ if err := publishAs(t, tenant, replacement); err == nil {140+ t.Fatalf("mutated %s replacement accepted", name)141+ }142+ })143+ }144+}145+146+func TestGrantRequestPreparedReplacementGoesStale(t *testing.T) {147+ for _, scenario := range []string{"pause", "revoke", "deny", "expire"} {148+ t.Run(scenario, func(t *testing.T) {149+ _, tenant := testTenant(t)150+ now := time.Now().Unix()151+ agent, _ := event.PublicKey(testAgentSecret)152+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"})153+ if err := publishAs(t, tenant, grant); err != nil {154+ t.Fatal(err)155+ }156+ tags := grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}})157+ requestTags := [][]string(nil)158+ if scenario == "expire" {159+ requestTags = [][]string{{"expiration", strconv.FormatInt(now+1, 10)}}160+ }161+ request := grantRequestEvent(t, testAgentSecret, grant, now, "please", tags, requestTags...)162+ if err := publishAs(t, tenant, request); err != nil {163+ t.Fatal(err)164+ }165+ review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), request, now)166+ if err != nil {167+ t.Fatal(err)168+ }169+ replacement := review.Unsigned170+ if scenario == "deny" {171+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 7, now+1, [][]string{{"e", request.ID}, {"p", agent}}, "-")); err != nil {172+ t.Fatal(err)173+ }174+ } else if scenario != "expire" {175+ action := "pauseagent"176+ if scenario == "revoke" {177+ action = "revokeagent"178+ }179+ if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, action, []json.RawMessage{json.RawMessage(`"` + agent + `"`)}); err != nil {180+ t.Fatal(err)181+ }182+ }183+ if err := event.Sign(&replacement, testOwnerSecret); err != nil {184+ t.Fatal(err)185+ }186+ if scenario == "expire" {187+ time.Sleep(2 * time.Second)188+ }189+ if err := publishAs(t, tenant, replacement); err == nil {190+ t.Fatalf("stale %s replacement accepted", scenario)191+ }192+ })193+ }194+}
A internal/daemon/grant_approvals.go +94 -0
@@ -0,0 +1,94 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strings"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+)10+11+// Keep the decision visible after the addressable grant has been superseded12+// or expired. Only archived grants tied to these exact requests are read.13+func (t *Tenant) grantApprovalHistory(ctx context.Context, items []approvalItem) ([]event.Event, error) {14+ ids, authors := []string{}, []string{}15+ for _, item := range items {16+ if item.Type == "grant" {17+ ids = append(ids, item.ID)18+ authors = append(authors, item.Asked...)19+ }20+ }21+ if len(ids) == 0 {22+ return nil, nil23+ }24+ idJSON, err := json.Marshal(ids)25+ if err != nil {26+ return nil, err27+ }28+ authorJSON, err := json.Marshal(uniqueStrings(authors))29+ if err != nil {30+ return nil, err31+ }32+ rows, err := t.store.DB().QueryContext(ctx, `SELECT raw FROM (33+ SELECT raw,created_at FROM events WHERE kind=30392 AND pubkey IN (SELECT value FROM json_each(?))34+ UNION ALL SELECT raw,created_at FROM agent_grant_revisions WHERE author IN (SELECT value FROM json_each(?))35+ ) WHERE EXISTS (SELECT 1 FROM json_each(json_extract(raw,'$.tags')) AS tag36+ WHERE json_extract(tag.value,'$[0]')='grant-request' AND json_extract(tag.value,'$[1]') IN (SELECT value FROM json_each(?)))37+ ORDER BY created_at DESC LIMIT ?`, string(authorJSON), string(authorJSON), string(idJSON), approvalScan)38+ if err != nil {39+ return nil, err40+ }41+ defer rows.Close()42+ var grants []event.Event43+ for rows.Next() {44+ var raw string45+ if err := rows.Scan(&raw); err != nil {46+ return nil, err47+ }48+ grant, err := event.Parse([]byte(raw))49+ if err != nil {50+ return nil, err51+ }52+ grants = append(grants, grant)53+ }54+ return grants, rows.Err()55+}56+57+// A grant is approved only by the operator's replacement grant. Reactions58+// cannot confer permissions or make an unapplied request look successful.59+func settleGrantApproval(item *approvalItem, rows []event.Event, now int64) {60+ for _, row := range rows {61+ if row.Kind != event.KIND_AGENT_GRANT || row.PubKey == item.Asker || !containsString(item.Asked, row.PubKey) || event.Tag(row, "grant-request") != item.ID || event.Tag(row, "grant-base") != event.Tag(item.Event, "E") || event.Tag(row, "d") != item.Asker {62+ continue63+ }64+ item.State = "answered"65+ item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: "approved", CreatedAt: row.CreatedAt}66+ return67+ }68+ for _, row := range rows {69+ if row.Kind != kindReaction || strings.TrimSpace(row.Content) != "-" || !containsString(item.Asked, row.PubKey) {70+ continue71+ }72+ item.State = "answered"73+ item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: "denied", CreatedAt: row.CreatedAt}74+ return75+ }76+ if item.Expires > 0 && item.Expires <= now {77+ item.State = "expired"78+ }79+}80+81+func (t *Tenant) reviewGrantApproval(ctx context.Context, item *approvalItem, now int64) {82+ if item.Type != "grant" || item.State != "open" {83+ return84+ }85+ review, err := t.community.ReviewAgentGrantRequest(ctx, item.Event, now)86+ if err != nil {87+ item.GrantError = err.Error()88+ if strings.HasPrefix(item.GrantError, "conflict:") {89+ item.State = "expired"90+ }91+ return92+ }93+ item.Grant = &review94+}
A internal/daemon/grant_approvals_test.go +157 -0
@@ -0,0 +1,157 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strings"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/community"11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+)13+14+func approvalGrantRequest(t *testing.T, grant event.Event, createdAt int64) event.Event {15+ t.Helper()16+ raw, err := json.Marshal(community.AgentGrantRequest{Base: grant.ID, Changes: community.AgentGrantChanges{Kinds: []int{30617, 30618}, Repos: []community.AgentRepo{{Owner: event.Tag(grant, "d"), Identifier: "seedmark", Level: "maintain"}}}})17+ if err != nil {18+ t.Fatal(err)19+ }20+ return signedEvent(t, testAgentSecret, 1111, createdAt, [][]string{{"request", "grant"}, {"grant", string(raw)}, {"E", grant.ID, "", grant.PubKey}, {"K", "30392"}, {"P", grant.PubKey}, {"e", grant.ID, "", grant.PubKey}, {"k", "30392"}, {"p", grant.PubKey}}, "Publish the Seedmark repository and its hosting manifest.")21+}22+23+func TestGrantApprovalAppliesAndRetainsDecision(t *testing.T) {24+ _, tenant := testTenant(t)25+ ctx := context.Background()26+ now := time.Now().Unix()27+ agent, _ := event.PublicKey(testAgentSecret)28+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}, []string{"room", tenant.meta.Name}, []string{"sites", "*"})29+ if err := publishAs(t, tenant, grant); err != nil {30+ t.Fatal(err)31+ }32+ request := approvalGrantRequest(t, grant, now-20)33+ if err := publishAs(t, tenant, request); err != nil {34+ t.Fatalf("request with no comment scope: %v", err)35+ }36+ detail, err := approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID})37+ if err != nil {38+ t.Fatal(err)39+ }40+ item := detail["item"].(map[string]any)41+ if item["type"] != "grant" || item["state"] != "open" || item["grant"] == nil {42+ t.Fatalf("missing actionable review: %v", item)43+ }44+ for _, actor := range []string{agent, grant.PubKey} {45+ if _, err := approvalCall(t, tenant, actor, "browseapproval", map[string]any{"id": request.ID}); err != nil {46+ t.Fatalf("participant cannot read review: %v", err)47+ }48+ }49+ if _, err := approvalCall(t, tenant, wikiKey(t, approvalOtherSecret), "browseapproval", map[string]any{"id": request.ID}); err == nil {50+ t.Fatal("stranger read grant request")51+ }52+ // A reaction alone must never make the UI claim access was granted.53+ positive := signedEvent(t, testOwnerSecret, 7, now-10, [][]string{{"e", request.ID}, {"p", agent}}, "+")54+ if err := publishAs(t, tenant, positive); err != nil {55+ t.Fatal(err)56+ }57+ detail, err = approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID})58+ if err != nil || detail["item"].(map[string]any)["state"] != "open" {59+ t.Fatalf("reaction settled an unapplied grant: %v %v", detail, err)60+ }61+ review, err := tenant.community.ReviewAgentGrantRequest(ctx, request, now)62+ if err != nil {63+ t.Fatal(err)64+ }65+ replacement := review.Unsigned66+ if err := event.Sign(&replacement, testOwnerSecret); err != nil {67+ t.Fatal(err)68+ }69+ if err := publishAs(t, tenant, replacement); err != nil {70+ t.Fatalf("reviewed replacement rejected: %v", err)71+ }72+ current, found, err := tenant.community.AgentGrant(ctx, agent)73+ if err != nil || !found || !containsInt(current.Scope.Kinds, 30617) || len(current.Scope.Sites) != 1 {74+ t.Fatalf("grant not applied or existing site lost: %+v %v", current, err)75+ }76+ // A later ordinary edit must not erase the approval's receipt.77+ manual := agentGrantEvent(t, agent, now+1, now+3600, []string{"k", "9"})78+ if err := publishAs(t, tenant, manual); err != nil {79+ t.Fatal(err)80+ }81+ for _, method := range []string{"browseapproval", "browseapprovals"} {82+ result, err := approvalCall(t, tenant, grant.PubKey, method, map[string]any{"id": request.ID})83+ if err != nil {84+ t.Fatal(err)85+ }86+ got := result["item"]87+ if method == "browseapprovals" {88+ got = approvalByID(wikiItems(result, "items"), request.ID)89+ }90+ item := got.(map[string]any)91+ answer := item["answer"].(map[string]any)92+ if item["state"] != "answered" || answer["decision"] != "approved" || answer["id"] != replacement.ID {93+ t.Fatalf("lost grant approval history: %v", item)94+ }95+ }96+}97+98+func TestGrantApprovalDenialAndStaleReview(t *testing.T) {99+ for _, scenario := range []string{"deny", "supersede"} {100+ t.Run(scenario, func(t *testing.T) {101+ _, tenant := testTenant(t)102+ now := time.Now().Unix()103+ agent, _ := event.PublicKey(testAgentSecret)104+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"})105+ if err := publishAs(t, tenant, grant); err != nil {106+ t.Fatal(err)107+ }108+ request := approvalGrantRequest(t, grant, now-20)109+ if err := publishAs(t, tenant, request); err != nil {110+ t.Fatal(err)111+ }112+ review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), request, now)113+ if err != nil {114+ t.Fatal(err)115+ }116+ if scenario == "deny" {117+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 7, now-10, [][]string{{"e", request.ID}, {"p", agent}}, "-")); err != nil {118+ t.Fatal(err)119+ }120+ } else if err := publishAs(t, tenant, agentGrantEvent(t, agent, now-5, now+3600, []string{"k", "1"})); err != nil {121+ t.Fatal(err)122+ }123+ replacement := review.Unsigned124+ if err := event.Sign(&replacement, testOwnerSecret); err != nil {125+ t.Fatal(err)126+ }127+ if err := publishAs(t, tenant, replacement); err == nil {128+ t.Fatal("obsolete request changed the grant")129+ }130+ detail, err := approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID})131+ if err != nil {132+ t.Fatal(err)133+ }134+ item := detail["item"].(map[string]any)135+ if item["grant"] != nil {136+ t.Fatalf("inactive request still actionable: %v", item)137+ }138+ if scenario == "deny" && (item["state"] != "answered" || item["answer"].(map[string]any)["decision"] != "denied") {139+ t.Fatalf("denial not shown: %v", item)140+ }141+ if scenario == "supersede" && (item["state"] != "expired" || !strings.HasPrefix(item["grant_error"].(string), "conflict:")) {142+ t.Fatalf("stale review not marked: %v", item)143+ }144+ })145+ }146+}147+148+func TestGrantRequestNotificationRequiresReview(t *testing.T) {149+ _, tenant := testTenant(t)150+ now := time.Now().Unix()151+ agent, _ := event.PublicKey(testAgentSecret)152+ request := approvalGrantRequest(t, agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}), now-20)153+ notices := tenant.pushNotices(context.Background(), request)154+ if len(notices) != 1 || notices[0].recipient != tenant.Policy().Owner || notices[0].category != pushApprovals || len(notices[0].actions) != 1 || notices[0].actions[0].Action != "review" || !strings.Contains(notices[0].url, "/approvals?id="+request.ID) {155+ t.Fatalf("grant request notification: %+v", notices)156+ }157+}
M internal/daemon/http.go +4 -0
M internal/daemon/jobs_test.go +38 -0
@@ -11,6 +11,7 @@ import (
1111 "time"1212 1313 "github.com/FelineStateMachine/tinyrelay/internal/event"14+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"1415 )1516 1617 // jobTenant returns a tenant with a member who requests jobs and an agent
@@ -169,6 +170,43 @@ func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {
169170 }170171 }171172 173+func TestMCPJobBuildersKeepRoomScope(t *testing.T) {174+ room := "private-work"175+ request, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{176+ "kind": float64(5001), "room": room,177+ "inputs": []any{map[string]any{"data": "hello", "type": "text"}},178+ }})179+ if err != nil {180+ t.Fatalf("request builder: %v", err)181+ }182+ if got := event.Tag(event.Event{Tags: request.Tags}, "h"); got != room {183+ t.Fatalf("request room = %q, want %q", got, room)184+ }185+186+ feedback, err := mcpBuildJobFeedback(mcp.Call{Arguments: map[string]any{187+ "e": strings.Repeat("a", 64), "p": strings.Repeat("b", 64),188+ "room": room, "status": "processing",189+ }})190+ if err != nil {191+ t.Fatalf("feedback builder: %v", err)192+ }193+ if got := event.Tag(event.Event{Tags: feedback.Tags}, "h"); got != room {194+ t.Fatalf("feedback room = %q, want %q", got, room)195+ }196+197+ requestEvent := event.Event{ID: strings.Repeat("c", 64), PubKey: strings.Repeat("d", 64), Kind: 5001, Tags: request.Tags}198+ result, err := mcpBuildJobResult(mcp.Call{Arguments: map[string]any{199+ "request": map[string]any{"id": requestEvent.ID, "pubkey": requestEvent.PubKey, "kind": float64(requestEvent.Kind), "created_at": float64(1), "tags": requestEvent.Tags, "content": ""},200+ "content": "done",201+ }})202+ if err != nil {203+ t.Fatalf("result builder: %v", err)204+ }205+ if got := event.Tag(event.Event{Tags: result.Tags}, "h"); got != room {206+ t.Fatalf("result room = %q, want %q", got, room)207+ }208+}209+172210 func TestJobAnswersWakeTheRequesterAsMentions(t *testing.T) {173211 _, tenant, member, agent := jobTenant(t)174212 ctx := context.Background()
A internal/daemon/mcp_grant_request.go +103 -0
@@ -0,0 +1,103 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "strings"8+ "time"9+ "unicode/utf8"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/community"12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"14+)15+16+const mcpGrantRequestShape = `Expected a signed kind 1111 NIP-22 event with matching E/K/P and e/k tags for the current kind 30392 grant, one p tag for its operator, request=grant and a grant JSON tag containing the current grant id and additive changes.`17+18+func mcpGrantRequestSchema() map[string]any {19+ text := map[string]any{"type": "string", "minLength": 1}20+ repo := mcp.Object(map[string]any{21+ "owner": mcpPubKey, "identifier": text,22+ "level": map[string]any{"type": "string", "enum": []string{community.AgentRepoPropose, community.AgentRepoRead, community.AgentRepoMaintain}},23+ }, "owner", "identifier", "level")24+ site := mcp.Object(map[string]any{"label": text, "ttl": map[string]any{"type": "integer", "minimum": 1, "maximum": community.AgentSiteTTLMax}, "encrypted": map[string]any{"type": "boolean"}}, "label")25+ changes := mcp.Object(map[string]any{26+ "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}},27+ "rooms": map[string]any{"type": "array", "items": text}, "repos": map[string]any{"type": "array", "items": repo},28+ "sites": map[string]any{"type": "array", "items": site}, "wiki": text, "jobs": text,29+ "rate": map[string]any{"type": "integer", "minimum": 1, "maximum": community.AgentRateMax},30+ })31+ return mcp.Object(map[string]any{"event": mcpEvent, "reason": map[string]any{"type": "string", "minLength": 1, "maxLength": 500}, "changes": changes})32+}33+34+func (t *Tenant) mcpGrantRequest(ctx context.Context, call mcp.Call) (mcp.Result, error) {35+ if raw, present := call.Arguments["event"]; present && raw != nil {36+ encoded, err := json.Marshal(raw)37+ if err != nil {38+ return mcp.Result{}, err39+ }40+ e, err := event.Parse(encoded)41+ if err == nil && e.PubKey != call.Actor {42+ err = errors.New("the event pubkey must match the authenticated agent")43+ }44+ if err == nil {45+ _, err = t.community.ReviewAgentGrantRequest(ctx, e, time.Now().Unix())46+ }47+ if err != nil {48+ return mcp.Failure("The grant request is not acceptable: "+err.Error()+"\n"+mcpGrantRequestShape, map[string]any{"expected": mcpGrantRequestShape}), nil49+ }50+ return t.mcpPublish(ctx, call, e), nil51+ }52+ unsigned, err := t.mcpBuildGrantRequest(ctx, call)53+ if err != nil {54+ return mcp.Failure(err.Error()+"\n"+mcpGrantRequestShape, map[string]any{"expected": mcpGrantRequestShape}), nil55+ }56+ return mcp.Value(map[string]any{"unsigned": unsigned, "next": mcpSignNext}), nil57+}58+59+func (t *Tenant) mcpBuildGrantRequest(ctx context.Context, call mcp.Call) (mcpUnsigned, error) {60+ reason := strings.TrimSpace(call.String("reason"))61+ if reason == "" || !utf8.ValidString(reason) || len([]rune(reason)) > 500 {62+ return mcpUnsigned{}, errors.New("reason must be 1 to 500 valid UTF-8 characters")63+ }64+ grant, ok, err := t.community.AgentGrant(ctx, call.Actor)65+ if err != nil {66+ return mcpUnsigned{}, err67+ }68+ if !ok || !grant.Active(time.Now().Unix()) {69+ return mcpUnsigned{}, errors.New("only an active granted agent can request more permissions")70+ }71+ changes, err := mcpGrantChanges(call.Arguments["changes"])72+ if err != nil {73+ return mcpUnsigned{}, err74+ }75+ grantJSON, err := json.Marshal(community.AgentGrantRequest{Base: grant.EventID, Changes: changes})76+ if err != nil {77+ return mcpUnsigned{}, err78+ }79+ tags := [][]string{{"E", grant.EventID, "", grant.Owner}, {"K", "30392"}, {"P", grant.Owner}, {"e", grant.EventID, "", grant.Owner}, {"k", "30392"}, {"request", "grant"}, {"grant", string(grantJSON)}, {"p", grant.Owner}}80+ unsigned := mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: reason}81+ request := event.Event{PubKey: call.Actor, Kind: unsigned.Kind, CreatedAt: unsigned.CreatedAt, Tags: unsigned.Tags, Content: unsigned.Content}82+ if _, err := t.community.ReviewAgentGrantRequest(ctx, request, time.Now().Unix()); err != nil {83+ return mcpUnsigned{}, err84+ }85+ return unsigned, nil86+}87+88+func mcpGrantChanges(raw any) (community.AgentGrantChanges, error) {89+ encoded, err := json.Marshal(raw)90+ if err != nil {91+ return community.AgentGrantChanges{}, errors.New("changes must be an object")92+ }93+ var changes community.AgentGrantChanges94+ decoder := json.NewDecoder(strings.NewReader(string(encoded)))95+ decoder.DisallowUnknownFields()96+ if err := decoder.Decode(&changes); err != nil {97+ return community.AgentGrantChanges{}, errors.New("changes must contain only supported grant fields")98+ }99+ if changes.Kinds == nil && changes.Rooms == nil && changes.Repos == nil && changes.Sites == nil && changes.Wiki == nil && changes.Jobs == nil && changes.Rate == nil {100+ return community.AgentGrantChanges{}, errors.New("changes must request at least one permission")101+ }102+ return changes, nil103+}
A internal/daemon/mcp_grant_request_test.go +74 -0
@@ -0,0 +1,74 @@
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+)12+13+func TestMCPGrantRequestBuildsOperatorBoundRequest(t *testing.T) {14+ _, tenant := testTenant(t)15+ agent, _ := event.PublicKey(testAgentSecret)16+ now := time.Now().Unix()17+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1111"})); err != nil {18+ t.Fatal(err)19+ }20+ changes := map[string]any{"kinds": []any{9}, "rooms": []any{"work"}}21+ unsigned, err := tenant.mcpBuildGrantRequest(context.Background(), mcp.Call{Actor: agent, Arguments: map[string]any{"reason": "Need chat replies", "changes": changes}})22+ if err != nil {23+ t.Fatal(err)24+ }25+ if unsigned.Kind != 1111 || unsigned.Content != "Need chat replies" {26+ t.Fatalf("unsigned request: %+v", unsigned)27+ }28+ if event.Tag(event.Event{Tags: unsigned.Tags}, "p") != tenant.Policy().Owner || event.Tag(event.Event{Tags: unsigned.Tags}, "request") != "grant" {29+ t.Fatalf("request is not bound to operator: %v", unsigned.Tags)30+ }31+ grant := event.Tag(event.Event{Tags: unsigned.Tags}, "grant")32+ if !strings.Contains(grant, `"base"`) || !strings.Contains(grant, `"kinds":[9]`) {33+ t.Fatalf("grant change payload: %s", grant)34+ }35+}36+37+func TestMCPGrantRequestRejectsUnsupportedChanges(t *testing.T) {38+ for _, raw := range []any{map[string]any{"unknown": "x"}, map[string]any{}} {39+ if _, err := mcpGrantChanges(raw); err == nil {40+ t.Errorf("accepted invalid changes: %#v", raw)41+ }42+ }43+}44+45+func TestMCPGrantRequestRejectsMissingGrantAndLongReason(t *testing.T) {46+ _, tenant := testTenant(t)47+ agent, _ := event.PublicKey(testAgentSecret)48+ call := mcp.Call{Actor: agent, Arguments: map[string]any{"reason": "need access", "changes": map[string]any{"kinds": []any{float64(9)}}}}49+ if _, err := tenant.mcpBuildGrantRequest(context.Background(), call); err == nil {50+ t.Fatal("agent without a grant accepted")51+ }52+ now := time.Now().Unix()53+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1111"})); err != nil {54+ t.Fatal(err)55+ }56+ call.Arguments["reason"] = strings.Repeat("x", 501)57+ if _, err := tenant.mcpBuildGrantRequest(context.Background(), call); err == nil {58+ t.Fatal("501-character reason accepted")59+ }60+}61+62+func TestMCPGrantRequestSchema(t *testing.T) {63+ schema := mcpGrantRequestSchema()64+ if err := mcp.Validate(schema, map[string]any{"event": map[string]any{}}); err != nil {65+ t.Fatalf("signed follow-up should be schema-valid: %v", err)66+ }67+ valid := map[string]any{"reason": "Need room chat", "changes": map[string]any{"kinds": []any{float64(9)}}}68+ if err := mcp.Validate(schema, valid); err != nil {69+ t.Fatal(err)70+ }71+ if err := mcp.Validate(schema, map[string]any{"reason": "", "changes": map[string]any{"kinds": []any{float64(9)}}}); err == nil {72+ t.Fatal("empty reason accepted")73+ }74+}
M internal/daemon/mcp_http.go +2 -2
@@ -20,7 +20,7 @@ func (t *Tenant) initMCP() error {
2020 t.mcp = &mcp.Server{2121 Tools: tools,2222 Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version},23- Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. upload_attachment stores a file for a room and returns a descriptor for the attachments argument of post_message, start_thread or reply_in_thread; read_attachment retrieves files your key may read. request_decision asks a person for an answer that arrives as a kind 7 reaction. request_job asks for a long task; a serving agent answers it with job_feedback and job_result, and list_jobs and read_job follow the work. add_callback registers an https URL that is woken with each new event matching a filter, so an agent that cannot hold a connection open still learns of new issues, pushes, room messages and requests.",23+ Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. upload_attachment stores a file for a room and returns a descriptor for the attachments argument of post_message, start_thread or reply_in_thread; read_attachment retrieves files your key may read. request_decision asks a person for an answer that arrives as a kind 7 reaction. request_grant lets an active agent ask its current grant operator for additive access and returns a kind 1111 request; only the operator can publish the replacement kind 30392 grant. request_job asks for a long task; a serving agent answers it with job_feedback and job_result, and list_jobs and read_job follow the work. add_callback registers an https URL that is woken with each new event matching a filter, so an agent that cannot hold a connection open still learns of new issues, pushes, room messages and requests.",2424 Authenticate: t.mcpAuthenticate,2525 Challenge: mcpChallenge,2626 Origin: t.mcpOrigin,
@@ -93,7 +93,7 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
9393 "- Methods: server/discover, tools/list and tools/call.",9494 "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_attachment, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_custom_views, list_join_requests, list_jobs and read_job.",9595 "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback, resume_callback, add_custom_view, remove_custom_view, pause_custom_view, resume_custom_view and run_custom_view. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature. A custom view POSTs the fenced code blocks of matching events to an https transform and keeps the SVG or PNG it returns as signed artifacts served at /views/<name>/<hash>.svg.",96- "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",96+ "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_grant, request_job, job_feedback and job_result. request_grant lets an active agent ask its current operator for additive access; the operator must publish the resulting kind 30392 grant. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",9797 "",9898 "## Files",9999 "",
M internal/daemon/mcp_tools.go +35 -7
@@ -71,9 +71,9 @@ 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 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.`75- 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.`76- 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.`74+ mcpJobRequestShape = `Expected a signed event of kind 5000 to 5127 or 5129 to 5999 with optional ["h","<room id>"], ["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.`75+ 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>"], optional ["h","<room id>"] and ["amount","<millisats>","<bolt11>"], with content empty or a partial result.`76+ mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"], ["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.`7878 mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."7979 mcpAnswerNote = " The answer arrives as a kind 7 reaction from the asked key on the published event: + approves, - declines, and any other content is the person's reply. Read the room or thread, or query kind 7 events with #e set to the event id, to collect it."
@@ -230,9 +230,10 @@ 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 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))234- 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))235- 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))233+ add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind, inputs and optional room, output, params, bid in millisats, relays and expiration. A room-scoped job keeps its h tag on feedback and results; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "room": mcpRoomID, "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))234+ add("job_feedback", "Report progress on a long task with a kind 7000 job feedback event. Pass e, p, status and optional room, info, amount in millisats, invoice and content. For a room-scoped request, pass the same room so private feedback stays private.", mcp.Object(map[string]any{"event": mcpEvent, "e": mcpHash, "p": mcpPubKey, "room": mcpRoomID, "status": mcpJobStatus, "info": mcpText, "amount": mcpMsats, "invoice": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobFeedback, mcpCheckJobFeedback, mcpJobFeedbackShape))235+ 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 room, amount and invoice. When request is supplied, its room and inputs are copied into the result.", mcp.Object(map[string]any{"event": mcpEvent, "request": map[string]any{"type": "object", "description": "The job request event being answered."}, "room": mcpRoomID, "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))236+ add("request_grant", "Ask your grant operator for additional agent permissions. Pass a short reason and additive changes; the relay targets your current grant operator and returns an unsigned NIP-22 request for you to sign. The request never changes your grant or grants authority; the operator must review and publish the replacement grant.", mcpGrantRequestSchema(), mcpPublishes, t.mcpGrantRequest)236237 return registry, err237238 }238239
@@ -917,6 +918,12 @@ func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) {
917918 return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5127 or 5129 to 5999")918919 }919920 tags := [][]string{}921+ if room := strings.TrimSpace(call.String("room")); room != "" {922+ if !community.ValidRoomID(room) {923+ return mcpUnsigned{}, errors.New("room must be a room id")924+ }925+ tags = append(tags, []string{"h", room})926+ }920927 if inputs, ok := call.Arguments["inputs"].([]any); ok {921928 for _, raw := range inputs {922929 input, _ := raw.(map[string]any)
@@ -1010,7 +1017,14 @@ func mcpBuildJobFeedback(call mcp.Call) (mcpUnsigned, error) {
10101017 if info := strings.TrimSpace(call.String("info")); info != "" {10111018 statusTag = append(statusTag, info)10121019 }1013- tags, err := mcpJobAmount(call, [][]string{statusTag, {"e", request}, {"p", requester}})1020+ tags := [][]string{statusTag, {"e", request}, {"p", requester}}1021+ if room := strings.TrimSpace(call.String("room")); room != "" {1022+ if !community.ValidRoomID(room) {1023+ return mcpUnsigned{}, errors.New("room must be a room id")1024+ }1025+ tags = append(tags, []string{"h", room})1026+ }1027+ tags, err := mcpJobAmount(call, tags)10141028 if err != nil {10151029 return mcpUnsigned{}, err10161030 }
@@ -1053,12 +1067,26 @@ func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) {
10531067 }10541068 tags := [][]string{}10551069 if hasRequest {1070+ if room := event.Tag(request, "h"); room != "" {1071+ tags = append(tags, []string{"h", room})1072+ }10561073 canonical, err := event.Canonical(request)10571074 if err != nil {10581075 return mcpUnsigned{}, err10591076 }10601077 tags = append(tags, []string{"request", string(canonical)})10611078 }1079+ if room := strings.TrimSpace(call.String("room")); room != "" {1080+ if !community.ValidRoomID(room) {1081+ return mcpUnsigned{}, errors.New("room must be a room id")1082+ }1083+ if hasRequest && event.Tag(request, "h") != room {1084+ return mcpUnsigned{}, errors.New("room must match the request")1085+ }1086+ if !hasRequest {1087+ tags = append(tags, []string{"h", room})1088+ }1089+ }10621090 tags = append(tags, []string{"e", id})10631091 if hasRequest {10641092 for _, tag := range request.Tags {
A internal/daemon/room_threads.go +93 -0
@@ -0,0 +1,93 @@
1+package daemon2+3+import (4+ "context"5+ "errors"6+7+ "github.com/FelineStateMachine/tinyrelay/internal/event"8+ "github.com/FelineStateMachine/tinyrelay/internal/storage"9+)10+11+func (t *Tenant) roomThreadRoot(ctx context.Context, actor, room, id string) (event.Event, error) {12+ seen := map[string]bool{}13+ for len(seen) < 64 && !seen[id] {14+ seen[id] = true15+ rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Limit: intPtr(1)}}, browseSession(t, actor))16+ if err != nil {17+ return event.Event{}, err18+ }19+ if len(rows) != 1 || event.Tag(rows[0], "h") != room {20+ break21+ }22+ root := rows[0]23+ if root.Kind != 9 && root.Kind != 11 && root.Kind != 12 && root.Kind != 40002 {24+ break25+ }26+ parent := event.RoomReplyRoot(root)27+ if parent == "" {28+ return root, nil29+ }30+ id = parent31+ }32+ return event.Event{}, errors.New("not found: thread")33+}34+35+// Follow indexed references so parent-only Buzz replies join the same thread36+// as marked root/reply pairs. The traversal is bounded against hostile trees.37+func (t *Tenant) roomThreadReplies(ctx context.Context, actor, room, root string) ([]event.Event, error) {38+ known := map[string]bool{root: true}39+ frontier := []string{root}40+ var replies []event.Event41+ for depth := 0; len(frontier) > 0 && depth < 64; depth++ {42+ var nextLevel []string43+ for start := 0; start < len(frontier); start += 100 {44+ end := min(start+100, len(frontier))45+ filter := event.Filter{Kinds: []int{9, 12}, Tags: map[string][]string{"h": {room}, "e": frontier[start:end]}}46+ var cursor *storage.EventCursor47+ for {48+ rows, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)49+ if err != nil {50+ return nil, err51+ }52+ for _, row := range rows {53+ if known[row.ID] || !known[event.RoomReplyRoot(row)] {54+ continue55+ }56+ known[row.ID] = true57+ replies = append(replies, row)58+ nextLevel = append(nextLevel, row.ID)59+ if len(replies) > 5000 {60+ return nil, errors.New("restricted: thread exceeds the browser limit")61+ }62+ }63+ if next == "" {64+ break65+ }66+ cursor, err = parseCollaborationCursor(next)67+ if err != nil {68+ return nil, err69+ }70+ }71+ }72+ frontier = nextLevel73+ }74+ if len(frontier) > 0 {75+ return nil, errors.New("restricted: thread exceeds the nesting limit")76+ }77+ sortEventsNewestFirst(replies)78+ return replies, nil79+}80+81+func roomThreadPage(rows []event.Event, cursor *storage.EventCursor, limit int) ([]event.Event, string) {82+ page := make([]event.Event, 0, limit)83+ for _, row := range rows {84+ if cursor != nil && (row.CreatedAt > cursor.CreatedAt || row.CreatedAt == cursor.CreatedAt && row.ID <= cursor.ID) {85+ continue86+ }87+ if len(page) == limit {88+ return page, collaborationCursor(collaborationItemFrom(page[len(page)-1]))89+ }90+ page = append(page, row)91+ }92+ return page, ""93+}
M internal/daemon/rooms_stream.go +8 -1
M internal/daemon/rooms_test.go +66 -0
@@ -254,6 +254,72 @@ func TestRoomsLifecycleRecordsAndBrowse(t *testing.T) {
254254 }255255 }256256 257+func TestBuzzKindNineRepliesUseNIP10ThreadRoots(t *testing.T) {258+ h := newRoomHarness(t)259+ root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "Buzz root")260+ parent := h.must("bob", event.KIND_CHAT, [][]string{261+ {"h", "main"}, {"e", root.ID, "", "reply"}, {"e", root.ID, "", "root"}, {"p", h.keys["alice"]},262+ }, "Buzz reply")263+ nested := h.must("alice", event.KIND_CHAT, [][]string{264+ {"h", "main"}, {"e", parent.ID, "", "reply"}, {"p", h.keys["bob"]},265+ }, "Nested reply")266+ thread, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 10})267+ if err != nil {268+ t.Fatal(err)269+ }270+ replies := thread["replies"].([]any)271+ if len(replies) != 2 {272+ t.Fatalf("Buzz thread replies = %d, want 2: %v", len(replies), replies)273+ }274+ seen := map[string]bool{}275+ for _, row := range replies {276+ seen[row.(map[string]any)["id"].(string)] = true277+ }278+ if !seen[parent.ID] || !seen[nested.ID] {279+ t.Fatalf("Buzz thread omitted replies: %v", seen)280+ }281+ room, err := h.browse("bob", "browseroom", map[string]any{"id": "main", "limit": 100})282+ if err != nil {283+ t.Fatal(err)284+ }285+ roomSeen := map[string]int{}286+ for _, row := range room["messages"].([]any) {287+ roomSeen[row.(map[string]any)["id"].(string)]++288+ }289+ for _, id := range []string{root.ID, parent.ID, nested.ID} {290+ if roomSeen[id] != 1 {291+ t.Fatalf("room timeline count for %s = %d, want 1", id, roomSeen[id])292+ }293+ }294+}295+296+func TestRoomThreadRejectsCrossRoomRootsAndPaginatesNestedReplies(t *testing.T) {297+ h := newRoomHarness(t)298+ root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root")299+ first := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, "first")300+ second := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", first.ID, "", "reply"}}, "second")301+ page, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 1})302+ if err != nil {303+ t.Fatal(err)304+ }305+ if len(page["replies"].([]any)) != 1 || page["next_cursor"] == "" {306+ t.Fatalf("first thread page = %v", page)307+ }308+ more, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 1, "cursor": page["next_cursor"]})309+ if err != nil || len(more["replies"].([]any)) != 1 {310+ t.Fatalf("second thread page = %v (%v)", more, err)311+ }312+ if page["replies"].([]any)[0].(map[string]any)["id"] == more["replies"].([]any)[0].(map[string]any)["id"] {313+ t.Fatal("thread cursor repeated a reply")314+ }315+ other := h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "other"}, {"visibility", "open"}}, "")316+ _ = other317+ if _, err := h.browse("bob", "browsethread", map[string]any{"id": "other", "event": root.ID}); err == nil {318+ t.Fatal("cross-room root was accepted")319+ }320+ _ = second321+}322+257323 func TestTypedRoomReaderPreservesSignedEvents(t *testing.T) {258324 h := newRoomHarness(t)259325 message := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"imeta", "url https://files.example/report.pdf", "m application/pdf", "filename report.pdf"}}, "typed room read")
M internal/daemon/services.go +4 -0
M internal/daemon/session_test.go +41 -0
@@ -103,6 +103,47 @@ func TestSignedSessionSurvivesNavigationAndRevokes(t *testing.T) {
103103 }104104 }105105 106+func TestChatPreferencesArePrivateAndDurablePerAccount(t *testing.T) {107+ a, tenant := testTenant(t)108+ login := httptest.NewRequest(http.MethodPost, "http://relay.test/session", strings.NewReader(""))109+ signRequest(t, login, "")110+ loginResult := httptest.NewRecorder()111+ a.ServeHTTP(loginResult, login)112+ if loginResult.Code != http.StatusOK {113+ t.Fatalf("login: %d %s", loginResult.Code, loginResult.Body.String())114+ }115+ cookie := loginResult.Result().Cookies()[0]116+ read := httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil)117+ read.AddCookie(cookie)118+ result := httptest.NewRecorder()119+ a.ServeHTTP(result, read)120+ if result.Code != http.StatusOK || result.Body.String() != `{"share_presence":false}` {121+ t.Fatalf("default preference: %d %s", result.Code, result.Body.String())122+ }123+ update := httptest.NewRequest(http.MethodPost, "http://relay.test/chat/preferences", strings.NewReader(`{"share_presence":true}`))124+ signRequest(t, update, `{"share_presence":true}`)125+ updated := httptest.NewRecorder()126+ a.ServeHTTP(updated, update)127+ if updated.Code != http.StatusOK || updated.Body.String() != `{"share_presence":true}` {128+ t.Fatalf("updated preference: %d %s", updated.Code, updated.Body.String())129+ }130+ if !tenant.chatSharePresence(context.Background(), tenant.Policy().Owner) {131+ t.Fatal("preference was not persisted")132+ }133+ reloaded := httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil)134+ reloaded.AddCookie(cookie)135+ reloadedResult := httptest.NewRecorder()136+ a.ServeHTTP(reloadedResult, reloaded)137+ if reloadedResult.Code != http.StatusOK || reloadedResult.Body.String() != `{"share_presence":true}` {138+ t.Fatalf("reloaded preference: %d %s", reloadedResult.Code, reloadedResult.Body.String())139+ }140+ guest := httptest.NewRecorder()141+ a.ServeHTTP(guest, httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil))142+ if guest.Code != http.StatusUnauthorized {143+ t.Fatalf("guest preference: %d", guest.Code)144+ }145+}146+106147 func TestTenantMaintenanceUsesPersistedRetention(t *testing.T) {107148 _, tenant := testTenant(t)108149 ctx := context.Background()
M internal/daemon/webui_rooms.go +21 -9
@@ -119,7 +119,7 @@ func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, curs
119119 if err := t.browseRead(ctx, actor); err != nil {120120 return webui.RoomPage{}, err121121 }122- room, _, err := t.roomFor(ctx, actor, roomID)122+ room, role, err := t.roomFor(ctx, actor, roomID)123123 if err != nil {124124 return webui.RoomPage{}, err125125 }
@@ -130,23 +130,25 @@ func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, curs
130130 if err != nil {131131 return webui.RoomPage{}, errors.New("invalid: thread cursor")132132 }133- roots, err := t.Query(ctx, []event.Filter{{IDs: []string{rootID}, Limit: intPtr(1)}}, browseSession(t, actor))133+ root, err := t.roomThreadRoot(ctx, actor, room.ID, rootID)134134 if err != nil {135135 return webui.RoomPage{}, err136136 }137- if len(roots) != 1 || event.Tag(roots[0], "h") != room.ID || (roots[0].Kind != event.KIND_THREAD && roots[0].Kind != event.KIND_CHAT && roots[0].Kind != event.KIND_RICH_CONTENT) {138- return webui.RoomPage{}, errors.New("not found: thread")139- }140- replies, next, err := t.roomMessages(ctx, actor, event.Filter{Kinds: []int{event.KIND_THREAD_REPLY}, Tags: map[string][]string{"h": {room.ID}, "e": {rootID}}}, position, normalizeRoomLimit(limit))137+ all, err := t.roomThreadReplies(ctx, actor, room.ID, root.ID)141138 if err != nil {142139 return webui.RoomPage{}, err143140 }144- targets := append([]event.Event{roots[0]}, replies...)141+ replies, next := roomThreadPage(all, position, normalizeRoomLimit(limit))142+ targets := append([]event.Event{root}, replies...)145143 edits, err := t.roomEdits(ctx, actor, room.ID, targets)146144 if err != nil {147145 return webui.RoomPage{}, err148146 }149- return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Root: &roots[0], Replies: replies, Edits: edits, NextCursor: next}, nil147+ members, err := t.webuiRoomMembers(ctx, room.ID, role)148+ if err != nil {149+ return webui.RoomPage{}, err150+ }151+ return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, Edits: edits, NextCursor: next}, nil150152 }151153 152154 func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]webui.RoomMember, error) {
@@ -163,7 +165,17 @@ func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]w
163165 if err != nil {164166 return nil, err165167 }166- members = append(members, webui.RoomMember{PubKey: row.PubKey, Role: row.Role, AddedAt: row.AddedAt, Agent: tenantRole == "agent"})168+ member := webui.RoomMember{PubKey: row.PubKey, Role: row.Role, AddedAt: row.AddedAt, Agent: tenantRole == "agent"}169+ if member.Agent {170+ grant, found, grantErr := t.community.AgentGrant(ctx, row.PubKey)171+ if grantErr != nil {172+ return nil, grantErr173+ }174+ if found {175+ member.Operator = grant.Owner176+ }177+ }178+ members = append(members, member)167179 }168180 return members, nil169181 }
A internal/event/room_reply.go +48 -0
A internal/event/room_reply_test.go +30 -0
M internal/gates/gates.go +26 -2
@@ -71,6 +71,9 @@ func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) err
7171 return fmt.Errorf("check agent grant: %w", err)7272 }7373 if !ok {74+ if community.IsAgentGrantRequest(e) {75+ return errors.New("restricted: grant requests require an active agent grant")76+ }7477 return nil7578 }7679 role, err := g.cfg.Community.Role(ctx, e.PubKey)
@@ -78,10 +81,20 @@ func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) err
7881 return fmt.Errorf("check agent role: %w", err)7982 }8083 if role != "" && role != "agent" {84+ if community.IsAgentGrantRequest(e) {85+ return errors.New("restricted: only the granted agent may request a grant change")86+ }8187 return nil8288 }83- if err := grant.Check(e, now); err != nil {84- return err89+ if community.IsAgentGrantRequest(e) {90+ if _, reviewErr := g.cfg.Community.ReviewAgentGrantRequest(ctx, e, now); reviewErr != nil {91+ return reviewErr92+ }93+ }94+ if !community.IsAgentGrantRequest(e) {95+ if err := grant.Check(e, now); err != nil {96+ return err97+ }8598 }8699 if err := g.jobReply(ctx, e, now, true); err != nil {87100 return err
@@ -107,6 +120,11 @@ func (g *Gate) agentGrantShape(ctx context.Context, e event.Event, now int64) er
107120 if !community.CanGrantAgents(role) {108121 return errors.New("restricted: only the owner or a moderator can grant an agent")109122 }123+ if event.Tag(e, "grant-request") != "" || event.Tag(e, "grant-base") != "" {124+ if err := g.cfg.Community.ValidateAgentGrantReplacement(ctx, e, now); err != nil {125+ return err126+ }127+ }110128 _, err = community.ParseAgentGrant(e, now)111129 return err112130 }
@@ -674,6 +692,12 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session
674692 // ban, repository privacy, policy, room and recipient checks.675693 func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *event.Filter) bool {676694 p := g.cfg.Policy()695+ if community.IsAgentGrantRequest(e) {696+ operator := event.Tag(e, "p")697+ if !contains(s.PubKeys, e.PubKey) && !contains(s.PubKeys, operator) {698+ return false699+ }700+ }677701 if g.cfg.Store != nil {678702 var held int679703 if err := g.cfg.Store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM (SELECT id FROM hidden_events WHERE id=? UNION ALL SELECT id FROM pending_events WHERE id=?)`, e.ID, e.ID).Scan(&held); err != nil || held > 0 {
M internal/gates/jobs.go +3 -0
M internal/mcp/tools.go +3 -2
A internal/mcp/unicode_schema_test.go +16 -0
M internal/storage/maintenance.go +5 -1
M internal/storage/save.go +29 -0
@@ -182,6 +182,11 @@ func replace(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {
182182 return fmt.Errorf("archive replaced wiki revision: %w", err)183183 }184184 }185+ if e.Kind == event.KIND_AGENT_GRANT {186+ if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE pubkey=? AND kind=? AND d=?", e.ID, e.PubKey, e.Kind, d); err != nil {187+ return fmt.Errorf("archive replaced agent grant: %w", err)188+ }189+ }185190 _, err = tx.ExecContext(ctx, "DELETE FROM events WHERE pubkey=? AND kind=? AND d=?", e.PubKey, e.Kind, d)186191 if err != nil {187192 return fmt.Errorf("replace event: %w", err)
@@ -199,6 +204,11 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string)
199204 if err != nil {200205 return nil201206 }207+ if kind == event.KIND_AGENT_GRANT {208+ if err := archiveAgentGrantDeletion(ctx, tx, e, parts[1], parts[2]); err != nil {209+ return err210+ }211+ }202212 if _, err := tx.ExecContext(ctx, "DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?", kind, e.PubKey, parts[2], e.CreatedAt); err != nil {203213 return fmt.Errorf("delete address: %w", err)204214 }
@@ -211,6 +221,9 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string)
211221 }212222 }213223 } else {224+ if err := archiveAgentGrantEventDeletion(ctx, tx, e, tag[1]); err != nil {225+ return err226+ }214227 if _, err := tx.ExecContext(ctx, "DELETE FROM events WHERE id=? AND (pubkey=? OR (kind=1059 AND EXISTS(SELECT 1 FROM tags WHERE event_id=events.id AND name='p' AND value=?)))", tag[1], e.PubKey, e.PubKey); err != nil {215228 return fmt.Errorf("delete event: %w", err)216229 }
@@ -228,6 +241,22 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string)
228241 return nil229242 }230243 244+func archiveAgentGrantDeletion(ctx context.Context, tx *sql.Tx, deletion event.Event, owner, d string) error {245+ _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE pubkey=? AND kind=? AND d=? AND created_at<=?", deletion.ID, owner, event.KIND_AGENT_GRANT, d, deletion.CreatedAt)246+ if err != nil {247+ return fmt.Errorf("archive deleted agent grant: %w", err)248+ }249+ return nil250+}251+252+func archiveAgentGrantEventDeletion(ctx context.Context, tx *sql.Tx, deletion event.Event, target string) error {253+ _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE id=? AND kind=? AND pubkey=?", deletion.ID, target, event.KIND_AGENT_GRANT, deletion.PubKey)254+ if err != nil {255+ return fmt.Errorf("archive deleted agent grant: %w", err)256+ }257+ return nil258+}259+231260 func insertEvent(ctx context.Context, tx *sql.Tx, e event.Event, mode string) (int64, error) {232261 raw, err := event.Canonical(e)233262 if err != nil {
M internal/storage/schema.go +1 -0
M internal/storage/store_test.go +25 -0
M internal/webui/approvals.go +92 -0
@@ -3,6 +3,7 @@ package webui
33 import (44 "context"55 "encoding/json"6+ "strconv"67 "strings"78 )89
@@ -23,8 +24,17 @@ type approvalView struct {
2324 Decision string2425 AnsweredAt int642526 AnswerContent string27+ Grant *grantRequestView2628 }2729 30+type grantRequestView struct {31+ Raw string32+ Error string33+ Rows []grantScopeRow34+}35+36+type grantScopeRow struct{ Label, Before, After string }37+2838 // approvalViews returns the items in the given states, in the order the2939 // backend listed them.3040 func approvalViews(value any, base string, states ...string) []approvalView {
@@ -81,9 +91,91 @@ func approvalViewFrom(item map[string]any, base string) approvalView {
8191 if answer := valueMap(item["answer"]); len(answer) > 0 {8292 view.Decision, view.AnsweredAt, view.AnswerContent = plainString(answer["decision"]), unixSeconds(answer["created_at"]), strings.TrimSpace(plainString(answer["content"]))8393 }94+ if view.Type == "grant" {95+ raw := valueMap(item["grant"])96+ rows := []grantScopeRow{}97+ if len(raw) > 0 {98+ rows = grantScopeRows(raw)99+ } else {100+ raw = map[string]any{"request_id": view.ID, "agent": view.Asker, "operator": joinJSONValues(item["asked"])}101+ }102+ encoded, _ := json.Marshal(raw)103+ view.Grant = &grantRequestView{Raw: string(encoded), Error: plainString(item["grant_error"]), Rows: rows}104+ }84105 return view85106 }86107 108+func grantScopeRows(review map[string]any) []grantScopeRow {109+ before := valueMap(review["before"])110+ after := valueMap(review["after"])111+ bs, as := valueMap(before["scope"]), valueMap(after["scope"])112+ rows := []grantScopeRow{{"agent", shortID(plainString(before["pubkey"])), shortID(plainString(after["pubkey"]))}, {"name", plainString(before["name"]), plainString(after["name"])}, {"rooms", joinJSONValues(bs["rooms"]), joinJSONValues(as["rooms"])}, {"repos", joinGrantRepos(bs["repos"]), joinGrantRepos(as["repos"])}, {"wiki", plainString(bs["wiki"]), plainString(as["wiki"])}, {"jobs", plainString(bs["jobs"]), plainString(as["jobs"])}, {"sites", joinGrantSites(bs["sites"]), joinGrantSites(as["sites"])}, {"kinds", joinJSONValues(bs["kinds"]), joinJSONValues(as["kinds"])}, {"rate", plainString(bs["rate"]), plainString(as["rate"])}}113+ for i := range rows {114+ if rows[i].Before == "" {115+ rows[i].Before = "none"116+ }117+ if rows[i].After == "" {118+ rows[i].After = "none"119+ }120+ }121+ return rows122+}123+124+func joinJSONValues(value any) string {125+ // JSON numbers do not unmarshal into []string. Render both numeric kind126+ // lists and textual lists through their JSON representation so the review127+ // table never silently loses a proposed permission.128+ var values []any129+ if encoded, err := json.Marshal(value); err == nil {130+ _ = json.Unmarshal(encoded, &values)131+ }132+ parts := make([]string, 0, len(values))133+ for _, item := range values {134+ switch typed := item.(type) {135+ case string:136+ parts = append(parts, typed)137+ case float64:138+ parts = append(parts, strconv.FormatInt(int64(typed), 10))139+ default:140+ if encoded, err := json.Marshal(item); err == nil {141+ parts = append(parts, string(encoded))142+ }143+ }144+ }145+ return strings.Join(parts, ", ")146+}147+148+func joinGrantRepos(value any) string {149+ var repos []map[string]any150+ if encoded, err := json.Marshal(value); err == nil {151+ _ = json.Unmarshal(encoded, &repos)152+ }153+ parts := make([]string, 0, len(repos))154+ for _, repo := range repos {155+ parts = append(parts, plainString(repo["owner"])+":"+plainString(repo["identifier"])+":"+plainString(repo["level"]))156+ }157+ return strings.Join(parts, ", ")158+}159+160+func joinGrantSites(value any) string {161+ var sites []map[string]any162+ if encoded, err := json.Marshal(value); err == nil {163+ _ = json.Unmarshal(encoded, &sites)164+ }165+ parts := make([]string, 0, len(sites))166+ for _, site := range sites {167+ part := plainString(site["label"])168+ if ttl := plainString(site["ttl"]); ttl != "" && ttl != "0" {169+ part += " ttl=" + ttl170+ }171+ if site["encrypted"] == true {172+ part += " encrypted"173+ }174+ parts = append(parts, part)175+ }176+ return strings.Join(parts, ", ")177+}178+87179 // includeApproval makes sure the request a notification opened is on the88180 // page: when the listing does not carry it, the single request is read and89181 // placed first. A request that cannot be read leaves the listing as it is.
M internal/webui/approvals_test.go +14 -0
M internal/webui/browse.html +3 -2
@@ -55,13 +55,14 @@
5555 5656 {{define "approvals"}}<section>5757 <h1>Approvals</h1>58-<p>Requests that wait for your decision. Each answer is a signed reaction; a reply opens the thread.</p>58+<p>Requests that wait for your decision. Review requests, answer questions and approve proposed changes.</p>5959 {{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to see the requests addressed to your key.</p>6060 {{else if .Error}}{{template "alert" .Error}}6161 {{else}}{{$open := approvalViews .Event .URL "open"}}{{$done := approvalViews .Event .URL "answered" "expired"}}{{if $open}}{{range $item := $open}}<approval-item id="approval-{{$item.ID}}" data-type="{{$item.Type}}" data-state="{{$item.State}}">6262 <header>{{template "nostrKey" $item.Asker}} in {{$item.Where}} | <time datetime="{{datetime $item.CreatedAt}}">{{when $item.CreatedAt}}</time>{{if $item.Expires}} | expires <time datetime="{{datetime $item.Expires}}">{{when $item.Expires}}</time>{{end}}</header>6363 <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}64-<footer>{{if ne $item.Type "question"}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-">{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>64+{{with $item.Grant}}<agent-grant-request request="{{$item.ID}}" data-review="{{.Raw}}"{{if .Error}} data-stale="true"{{end}}><h4>Proposed grant change</h4><table><thead><tr><th>scope</th><th>current</th><th>proposed</th></tr></thead><tbody>{{range .Rows}}{{if ne .Before .After}}<tr><th>{{.Label}}</th><td>{{.Before}}</td><td>{{.After}}</td></tr>{{end}}{{end}}</tbody></table>{{with .Error}}<p role="alert">{{.}}</p>{{else}}<button type="button" data-grant-approve disabled>Approve and grant</button>{{end}}<button type="button" data-grant-deny disabled>Deny</button></agent-grant-request>{{end}}65+<footer>{{if and (ne $item.Type "question") (ne $item.Type "grant")}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-">{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>6566 </approval-item>6667 {{end}}{{else}}<p>Nothing waits for your decision.</p>6768 {{end}}{{if $done}}<h3>Answered</h3>
A internal/webui/chat-activity.go +184 -0
@@ -0,0 +1,184 @@
1+package webui2+3+import (4+ "bytes"5+ "context"6+ "encoding/json"7+ "net/http"8+ "net/url"9+ "sort"10+ "strings"11+ "time"12+)13+14+type ChatActivityReader interface {15+ ReadChatActivity(context.Context, string, string, string) (any, error)16+}17+18+type chatActivityItem struct {19+ Type, ID, Author, Kind, State, Status, Title, Content, Room, Decision string20+ CreatedAt, Expires int6421+ Answer string22+ CanDecide, Question, Encrypted bool23+ Result *chatActivityResult24+}25+type chatActivityResult struct{ ID, Content string }26+type chatActivityPage struct {27+ Items []chatActivityItem28+ Endpoint, Room, Actor, Error string29+}30+31+func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatActivityItem {32+ items := []chatActivityItem{}33+ for _, raw := range collaborationSlice(jobs, "items") {34+ item := valueMap(raw)35+ id := plainString(item["id"])36+ if !eventIDPattern.MatchString(id) {37+ continue38+ }39+ ev := valueMap(item["event"])40+ title := firstString(tagValues(ev, "subject"))41+ if title == "" {42+ title = strings.TrimSpace(plainString(item["content"]))43+ }44+ if title == "" {45+ for _, raw := range collaborationSlice(item, "inputs") {46+ input := valueMap(raw)47+ if plainString(input["type"]) == "text" {48+ title = plainString(input["data"])49+ break50+ }51+ }52+ }53+ if title == "" {54+ title = "Agent task"55+ }56+ title = truncateActivity(title, 100)57+ row := chatActivityItem{Type: "job", ID: id, Author: plainString(item["requester"]), Kind: plainString(item["kind"]), State: plainString(item["state"]), Status: plainString(item["status"]), Title: title, Content: plainString(item["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"])}58+ row.Encrypted, _ = item["encrypted"].(bool)59+ if row.Encrypted {60+ row.Title = "Encrypted task"61+ row.Content = "Open the task in its originating client."62+ }63+ if feedback := valueMap(item["feedback"]); len(feedback) > 0 && !row.Encrypted {64+ row.Author = plainString(feedback["provider"])65+ row.Content = strings.TrimSpace(plainString(feedback["info"]) + "\n" + plainString(feedback["content"]))66+ }67+ if result := valueMap(item["result"]); eventIDPattern.MatchString(plainString(result["id"])) {68+ row.Result = &chatActivityResult{ID: plainString(result["id"]), Content: plainString(result["content"])}69+ row.Status = "success"70+ if row.Encrypted {71+ row.Result.Content = ""72+ }73+ }74+ if row.Status == "" {75+ row.Status = "queued"76+ }77+ items = append(items, row)78+ }79+ for _, raw := range collaborationSlice(approvals, "items") {80+ item := valueMap(raw)81+ id := plainString(item["id"])82+ if !eventIDPattern.MatchString(id) || actor == "" {83+ continue84+ }85+ asker := plainString(item["asker"])86+ can := approvalActorMayDecide(item, actor)87+ if !can && asker != actor {88+ continue89+ }90+ title := strings.TrimSpace(plainString(item["subject"]))91+ if title == "" {92+ title = plainString(item["content"])93+ }94+ state := plainString(item["state"])95+ expires := unixSeconds(item["expires"])96+ if state == "open" && expires > 0 && expires <= time.Now().Unix() {97+ state = "expired"98+ }99+ answer := valueMap(item["answer"])100+ decision := plainString(answer["decision"])101+ items = append(items, chatActivityItem{Type: "approval", ID: id, Author: asker, Kind: plainString(item["kind"]), State: state, Title: truncateActivity(title, 150), Content: plainString(item["content"]), Answer: plainString(answer["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: expires, CanDecide: can && state == "open", Question: plainString(item["type"]) == "question", Decision: decision})102+ }103+ sort.SliceStable(items, func(i, j int) bool {104+ if items[i].CreatedAt == items[j].CreatedAt {105+ return items[i].ID < items[j].ID106+ }107+ return items[i].CreatedAt < items[j].CreatedAt108+ })109+ return items110+}111+func truncateActivity(value string, max int) string {112+ runes := []rune(value)113+ if len(runes) > max {114+ return string(runes[:max]) + "…"115+ }116+ return value117+}118+func approvalActorMayDecide(item map[string]any, actor string) bool {119+ if actor == "" || plainString(item["asker"]) == actor {120+ return false121+ }122+ for _, key := range stringValues(item["asked"]) {123+ if key == actor {124+ return true125+ }126+ }127+ return false128+}129+func stringValues(value any) []string {130+ raw, err := json.Marshal(value)131+ if err != nil {132+ return nil133+ }134+ var values []string135+ if json.Unmarshal(raw, &values) != nil {136+ return nil137+ }138+ return values139+}140+141+func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage {142+ endpoint := "/chat/activity?room=" + url.QueryEscape(room)143+ if root != "" {144+ endpoint += "&root=" + url.QueryEscape(root)145+ }146+ page := chatActivityPage{Endpoint: endpoint, Room: room, Actor: actor}147+ reader, ok := a.backend.(ChatActivityReader)148+ if !ok {149+ return page150+ }151+ result, err := reader.ReadChatActivity(ctx, actor, room, root)152+ if err != nil {153+ page.Error = "Agent activity is unavailable."154+ return page155+ }156+ data := valueMap(result)157+ page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room)158+ return page159+}160+func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {161+ w.Header().Set("Cache-Control", "private, no-store")162+ actor, err := a.resolveActor(r)163+ if err != nil {164+ http.Error(w, "Sign in to see chat activity.", http.StatusUnauthorized)165+ return166+ }167+ room, root := r.URL.Query().Get("room"), r.URL.Query().Get("root")168+ if !roomIDPattern.MatchString(room) || root != "" && !eventIDPattern.MatchString(root) {169+ http.Error(w, "Invalid room or thread.", http.StatusBadRequest)170+ return171+ }172+ page := a.chatActivity(r.Context(), actor, room, root)173+ if page.Error != "" {174+ http.Error(w, page.Error, http.StatusForbidden)175+ return176+ }177+ w.Header().Set("Content-Type", "text/html; charset=utf-8")178+ var body bytes.Buffer179+ if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil {180+ http.Error(w, "Activity rendering failed.", 500)181+ return182+ }183+ _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r))))184+}
A internal/webui/chat-activity.html +8 -0
@@ -0,0 +1,8 @@
1+{{define "chatActivity"}}<chat-activity id="chat-activity" endpoint="{{.Endpoint}}" room="{{.Room}}" actor="{{.Actor}}" fx-action="{{.Endpoint}}" fx-trigger="tiny:activity-refresh" fx-target="#chat-activity-cards" fx-swap="morph"><div id="chat-activity-cards">{{template "chatActivityCards" .}}</div></chat-activity>{{end}}2+{{define "chatActivityResponse"}}<div id="chat-activity-cards">{{template "chatActivityCards" .}}</div>{{end}}3+{{define "chatActivityCards"}}{{if .Error}}<p role="status">{{.Error}}</p>{{else}}{{range .Items}}<article id="activity-{{.ID}}" data-activity="{{.Type}}" data-state="{{.State}}" data-event="{{.ID}}">4+{{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}5+<p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals?id={{.ID}}">Open request</a></p>6+{{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}"{{if .Question}} question{{end}}>{{if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit">Reply</button>{{else}}<button type="submit" name="reaction" value="+">Approve</button><button type="submit" name="reaction" value="-">Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}7+{{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>{{template "nostrName" .Author}}</small></p><div>{{chatMarkdown .Content}}</div>{{with .Result}}{{if .Content}}<h4>Result</h4><div>{{chatMarkdown .Content}}</div>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}8+</article>{{end}}{{end}}{{end}}
A internal/webui/chat-activity.js +83 -0
@@ -0,0 +1,83 @@
1+(() => {2+ "use strict";3+ const tiny=globalThis.tiny;4+ class ChatActivity extends HTMLElement {5+ connectedCallback() {6+ if (this.running) return;7+ this.running=true; this.stopped=false; this.version="";8+ this.onEvent=e=>{ if (e.detail?.room===this.getAttribute("room")) this.schedule(); };9+ this.onVisibility=()=>{ if (!document.hidden) this.refresh(); };10+ this.onLogout=()=>{ this.stopped=true; this.abort?.(); this.replaceChildren(); };11+ this.onConfig=e=>{12+ const cfg=e.detail?.cfg;13+ if(e.target!==this || cfg?.trigger?.target!==this) return;14+ const force=this.forceRefresh;15+ cfg.credentials="same-origin"; cfg.cache="no-store"; cfg.transition=false;16+ cfg.headers={...cfg.headers,Accept:"text/html"};17+ this.abort=cfg.abort;18+ cfg.swap=()=>this.swap(cfg,force);19+ };20+ this.onFinally=e=>{ if(e.target===this) { this.loading=false; this.abort=null; queueMicrotask(()=>{this.resolveRefresh?.();this.resolveRefresh=null;}); } };21+ this.onError=e=>{ if(e.target===this && e.detail?.error?.name!=="AbortError") this.setAttribute("title","Chat activity could not be refreshed."); };22+ document.addEventListener("tiny:room-event",this.onEvent);23+ document.addEventListener("visibilitychange",this.onVisibility);24+ document.addEventListener("tiny:logout",this.onLogout);25+ this.addEventListener("fx:config",this.onConfig);26+ this.addEventListener("fx:finally",this.onFinally);27+ this.addEventListener("fx:error",this.onError);28+ this.timer=setInterval(()=>this.refresh(),15000);29+ queueMicrotask(()=>{if(this.isConnected && !this.stopped){this.dispatchEvent(new CustomEvent("fx:process",{bubbles:true}));this.refresh();}});30+ }31+ disconnectedCallback() {32+ this.running=false;this.stopped=true;this.abort?.();clearInterval(this.timer);clearTimeout(this.debounce);33+ document.removeEventListener("tiny:room-event",this.onEvent);document.removeEventListener("visibilitychange",this.onVisibility);document.removeEventListener("tiny:logout",this.onLogout);34+ this.removeEventListener("fx:config",this.onConfig);this.removeEventListener("fx:finally",this.onFinally);this.removeEventListener("fx:error",this.onError);35+ this.resolveRefresh?.();this.resolveRefresh=null;this.loading=false;36+ }37+ schedule() { if(!this.debounce)this.debounce=setTimeout(()=>{this.debounce=null;this.refresh();},400); }38+ async read() {39+ const url=new URL(tiny.localPath(this.getAttribute("endpoint")),location.href);40+ if(url.origin!==location.origin)throw Error("Invalid activity address.");41+ const response=await fetch(url,{credentials:"same-origin",cache:"no-store"});42+ if(!response.ok)throw Error("Chat activity could not be refreshed.");43+ return response.text();44+ }45+ async refresh(force=false) {46+ if(this.loading || this.stopped || document.hidden || (!force && this.contains(document.activeElement)) || !this.__fixi)return;47+ this.loading=true;this.forceRefresh=force;48+ return new Promise(resolve=>{this.resolveRefresh=resolve;this.dispatchEvent(new CustomEvent("tiny:activity-refresh"));});49+ }50+ swap(cfg,force=false) {51+ if(this.stopped || !this.isConnected || !cfg.response.ok || cfg.text===this.version || (!force && this.contains(document.activeElement)))return;52+ const opened=new Set([...this.querySelectorAll("details[open][data-task]")].map(el=>el.dataset.task));53+ morph(cfg.target,cfg.text);54+ cfg.target.querySelectorAll("details[data-task]").forEach(el=>{el.open=opened.has(el.dataset.task);});55+ cfg.target.querySelectorAll("chat-decision").forEach(el=>{if(!el.form?.isConnected){el.form=null;el.output=null;el.connectedCallback();}});56+ this.version=cfg.text;57+ }58+ }59+ class ChatDecision extends tiny.ui.FormElement {60+ connectedCallback() { if(this.form) return; super.connectedCallback(); }61+ async submit(form) {62+ const host=this.closest("chat-activity"),id=this.getAttribute("event"),author=this.getAttribute("pubkey"),actor=this.getAttribute("actor"),room=this.getAttribute("room");63+ const signer=tiny.signer();64+ if(!signer || await signer.getPublicKey()!==actor || tiny.signer()!==signer) throw Error("Connect the signer for this account.");65+ const fresh=document.createElement("template"); fresh.innerHTML=await host.read();66+ const allowed=[...fresh.content.querySelectorAll("chat-decision")].find(el=>el.getAttribute("event")===id && el.getAttribute("actor")===actor && el.getAttribute("pubkey")===author);67+ if(!allowed) { host.schedule(); throw Error("This request is no longer waiting for your answer."); }68+ let unsigned;69+ if(this.hasAttribute("question")) {70+ const content=(form.elements.content?.value || "").trim(); if(!content) throw Error("Write an answer first.");71+ unsigned={kind:1111,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["E",id,"",author],["K",this.getAttribute("kind")],["P",author],["e",id,"",author],["k",this.getAttribute("kind")],["p",author]],content};72+ } else {73+ const choice=this.submitter?.value;74+ if(!["+","-"].includes(choice)) throw Error("Choose Approve or Deny.");75+ unsigned={kind:7,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["e",id,"",author],["p",author],["k",this.getAttribute("kind")]],content:choice};76+ }77+ this.report("Signing…"); await tiny.signing.publish(unsigned);78+ this.report("Answer sent."); this.querySelectorAll("button,textarea").forEach(el=>el.disabled=true);79+ await host.refresh(true);80+ }81+ }82+ customElements.define("chat-activity",ChatActivity); customElements.define("chat-decision",ChatDecision);83+})();
A internal/webui/chat-presence.js +159 -0
@@ -0,0 +1,159 @@
1+(() => {2+ "use strict";3+ const tiny = globalThis.tiny;4+ const PRESENCE = 20001, TYPING = 20002;5+ const hex = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);6+ const tag = (event, name) => (event.tags || []).find(item => item[0] === name)?.[1] || "";7+ const seconds = () => Math.floor(Date.now() / 1000);8+ const parse = (event, room, now = seconds()) => {9+ if (!event || ![PRESENCE, TYPING].includes(event.kind) || !hex(event.pubkey)) return null;10+ const scope = tag(event, "h");11+ if (scope !== room && !(event.kind === PRESENCE && !scope)) return null;12+ const ttl = event.kind === PRESENCE ? 180 : 5;13+ if (!Number.isSafeInteger(event.created_at) || event.created_at > now + 5 || event.created_at + ttl <= now) return null;14+ let status = String(event.content || "").trim();15+ try { const data = JSON.parse(status); if (typeof data?.status === "string") status = data.status; if (typeof data?.typing === "boolean") status = data.typing ? "typing" : "stopped"; } catch {}16+ if (event.kind === TYPING) status = ["stopped", "false", "idle", "offline"].includes(status.toLowerCase()) ? "stopped" : "typing";17+ return {pubkey:event.pubkey, kind:event.kind, status:status.slice(0,128) || "online", at:event.created_at, expires:event.created_at+ttl};18+ };19+ const readPreference = async signal => {20+ const response = await fetch(tiny.localPath("/chat/preferences"), {credentials:"same-origin",cache:"no-store",signal});21+ if (!response.ok) throw Error("Chat preferences could not be loaded.");22+ return (await response.json()).share_presence === true;23+ };24+ class ChatPreferences extends HTMLElement {25+ connectedCallback() {26+ this.actor = this.getAttribute("actor"); this.input = this.querySelector("input"); this.output = this.querySelector("output");27+ if (!hex(this.actor) || !this.input) return;28+ this.controller = new AbortController(); this.input.disabled = true;29+ this.onChange = () => this.save();30+ this.input.addEventListener("change",this.onChange);31+ this.onLogout = () => { this.controller.abort(); this.input.disabled = true; };32+ document.addEventListener("tiny:logout",this.onLogout);33+ this.load();34+ }35+ disconnectedCallback() { this.controller?.abort(); this.input?.removeEventListener("change",this.onChange); document.removeEventListener("tiny:logout",this.onLogout); }36+ async load() {37+ try {38+ this.saved = await readPreference(this.controller.signal);39+ if (!this.isConnected || this.controller.signal.aborted) return;40+ this.input.checked = this.saved; this.input.disabled = false;41+ } catch(error) { if (error.name !== "AbortError") this.output.textContent = error.message; }42+ }43+ async save() {44+ if (this.saving) return;45+ this.saving = true; this.input.disabled = true;46+ try {47+ const signer = tiny.signer();48+ if (!signer || await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) throw Error("Connect the signer for this account.");49+ if (!this.isConnected || this.controller.signal.aborted) return;50+ const response = await tiny.signedFetch("/chat/preferences","POST",JSON.stringify({share_presence:this.input.checked}),{contentType:"application/json",signal:this.controller.signal});51+ this.saved = (await response.json()).share_presence === true;52+ if (!this.isConnected || this.controller.signal.aborted) return;53+ this.input.checked = this.saved; this.output.textContent = "Saved.";54+ document.dispatchEvent(new CustomEvent("tiny:chat-preferences",{detail:{actor:this.actor,share_presence:this.saved}}));55+ } catch(error) {56+ this.input.checked = this.saved;57+ if (error.name !== "AbortError") this.output.textContent = error.message;58+ } finally { this.saving = false; this.input.disabled = this.controller.signal.aborted; }59+ }60+ }61+ class ChatPresence extends HTMLElement {62+ connectedCallback() {63+ this.room = this.getAttribute("room"); this.actor = this.getAttribute("actor");64+ if (!/^[a-z0-9_-]{1,64}$/.test(this.room || "")) return;65+ this.values = new Map(); this.entries = new Map(); this.last = {}; this.pending = new Set(); this.generation = 0;66+ this.status = this.querySelector("output"); this.controller = new AbortController();67+ this.enabled = false;68+ this.onEvent = event => { if (event.detail?.room === this.room) this.receive(event.detail.event); };69+ this.onInput = event => { if (event.target.matches('room-compose textarea')) { this.inputAt = seconds(); this.publish(TYPING, event.target.value ? "typing" : "stopped"); } };70+ this.onVisibility = () => { this.generation++; this.values.clear(); this.paint(); if (document.hidden) { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); } else { this.loadPreference(); this.tick(); } };71+ this.onIdentity = () => { this.enabled = false; this.generation++; this.values.clear(); this.paint(); };72+ this.onSigner = () => { this.onIdentity(); this.failed = false; this.loadPreference(); };73+ this.onPreference = event => { if (event.detail?.actor === this.actor) this.applyPreference(event.detail.share_presence === true); };74+ document.addEventListener("tiny:room-event", this.onEvent);75+ document.addEventListener("input", this.onInput);76+ document.addEventListener("visibilitychange", this.onVisibility);77+ document.addEventListener("tiny:signer", this.onSigner);78+ document.addEventListener("tiny:logout", this.onIdentity);79+ document.addEventListener("tiny:chat-preferences",this.onPreference);80+ this.loadPreference();81+ this.timer = setInterval(() => this.tick(),1000);82+ }83+ disconnectedCallback() {84+ this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true);85+ this.enabled = false; this.generation++; this.controller?.abort(); clearInterval(this.timer);86+ document.removeEventListener("tiny:room-event",this.onEvent); document.removeEventListener("input",this.onInput);87+ document.removeEventListener("visibilitychange",this.onVisibility); document.removeEventListener("tiny:signer",this.onSigner); document.removeEventListener("tiny:logout",this.onIdentity);88+ document.removeEventListener("tiny:chat-preferences",this.onPreference); this.values?.clear();89+ }90+ applyPreference(enabled) {91+ if (this.enabled === enabled) return;92+ this.enabled = enabled; this.generation++;93+ if (enabled) this.tick();94+ else { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); }95+ }96+ async loadPreference() {97+ if (!hex(this.actor) || this.loadingPreference || this.failed || this.controller.signal.aborted) return;98+ this.loadingPreference = true; const generation = this.generation;99+ try {100+ const enabled = await readPreference(this.controller.signal);101+ if (this.isConnected && !this.controller.signal.aborted && generation === this.generation) this.applyPreference(enabled);102+ } catch { this.applyPreference(false); }103+ finally { this.loadingPreference = false; this.preferenceCheckedAt = seconds(); }104+ }105+ receive(event) {106+ const value = parse(event,this.room); if (!value || value.pubkey === this.actor) return;107+ const id = value.kind+":"+value.pubkey, previous = this.values.get(id);108+ if (previous && previous.at > value.at) return;109+ this.values.set(id,value);110+ while (this.values.size > 256) this.values.delete(this.values.keys().next().value);111+ this.paint();112+ }113+ tick() {114+ if (document.hidden) return;115+ this.paint();116+ if (seconds()-(this.preferenceCheckedAt || 0) >= 60) this.loadPreference();117+ if (this.enabled) this.publish(PRESENCE,"online");118+ }119+ async publish(kind, content, leaving = false) {120+ if ((!this.enabled && !leaving) || (!this.last[kind] && leaving) || (document.hidden && !leaving) || !hex(this.actor) || this.pending.has(kind)) return;121+ const at = seconds(), interval = kind === PRESENCE ? 60 : 3;122+ if (!leaving && at-(this.last[kind] || 0) < interval) return;123+ const signer = tiny.signer?.(); if (!signer?.signEvent) return;124+ const generation = this.generation;125+ this.pending.add(kind);126+ try {127+ if (await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) return;128+ if (!leaving && (generation !== this.generation || !this.isConnected || !this.enabled || document.hidden)) return;129+ await tiny.signing.publish({kind, created_at:at, tags:[["h",this.room]], content});130+ this.last[kind] = at;131+ } catch {132+ this.enabled = false; this.failed = true;133+ if (this.status) this.status.textContent = "Presence could not be shared. Reconnect your signer to try again.";134+ } finally { this.pending.delete(kind); }135+ }136+ paint() {137+ if (!this.status) return;138+ const now = seconds(); for (const [id,value] of this.values) if (value.expires <= now) this.values.delete(id);139+ const values = [...this.values.values()].filter(v => v.status !== "offline" && v.status !== "stopped"), typers = new Set(values.filter(v => v.kind === TYPING).map(v => v.pubkey));140+ const visible = values.filter(v => v.kind === TYPING || !typers.has(v.pubkey)).sort((a,b) => a.pubkey.localeCompare(b.pubkey)).slice(0,8);141+ const active = new Set(visible.map(value => value.pubkey));142+ for (const [key, entry] of this.entries) if (!active.has(key)) { entry.node.remove(); this.entries.delete(key); }143+ visible.forEach((value,index) => {144+ let entry = this.entries.get(value.pubkey);145+ if (!entry) {146+ const node = document.createElement("span"), name = document.createElement("nostr-name"), status = document.createElement("span");147+ name.setAttribute("pubkey",value.pubkey); name.textContent = value.pubkey.slice(0,12);148+ node.append(name,status); entry = {node,status}; this.entries.set(value.pubkey,entry);149+ }150+ const text = " "+(value.kind === TYPING ? "is typing…" : value.status);151+ if (entry.status.textContent !== text) entry.status.textContent = text;152+ if (this.status.children[index] !== entry.node) this.status.insertBefore(entry.node,this.status.children[index] || null);153+ });154+ }155+ }156+ tiny.roomPresence = Object.freeze({parse, PRESENCE,TYPING});157+ customElements.define("chat-presence",ChatPresence);158+ customElements.define("chat-preferences",ChatPreferences);159+})();
A internal/webui/chat_activity_http_test.go +83 -0
@@ -0,0 +1,83 @@
1+package webui2+3+import (4+ "context"5+ "errors"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/policy"12+)13+14+type activityHTTPBackend struct {15+ roomsBackend16+ seenActor, seenRoom, seenRoot string17+ denied bool18+}19+20+func (b *activityHTTPBackend) ReadChatActivity(_ context.Context, actor, room, root string) (any, error) {21+ b.seenActor, b.seenRoom, b.seenRoot = actor, room, root22+ if b.denied {23+ return nil, errors.New("private details that must stay hidden")24+ }25+ return map[string]any{"approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview"}}}, nil26+}27+28+func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {29+ b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}30+ actor := roomOwner31+ app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})32+ if err != nil {33+ t.Fatal(err)34+ }35+ req := httptest.NewRequest(http.MethodGet, "/chat/activity?room=general&root="+roomThread, nil)36+ req.RequestURI = "/r/team/chat/activity?room=general&root=" + roomThread37+ res := httptest.NewRecorder()38+ app.ServeHTTP(res, req)39+ body := res.Body.String()40+ if res.Code != 200 || res.Header().Get("Cache-Control") != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread {41+ t.Fatalf("request context: %d %v %#v", res.Code, res.Header(), b)42+ }43+ for _, want := range []string{`<chat-decision`, `href="/r/team/approvals?id=` + roomReply, `Approve &lt;script&gt;bad()&lt;/script&gt;`, `<noscript>`} {44+ if !strings.Contains(body, want) {45+ t.Errorf("missing %q in %s", want, body)46+ }47+ }48+ if strings.Contains(body, "class=") || strings.Contains(body, "<script>bad()") {49+ t.Fatal("unsafe markup")50+ }51+ actor = roomGuest52+ res = httptest.NewRecorder()53+ app.ServeHTTP(res, req)54+ if strings.Contains(res.Body.String(), "chat-decision") || strings.Contains(res.Body.String(), "Review the preview") {55+ t.Fatal("approval shown to unaddressed actor")56+ }57+ b.denied = true58+ res = httptest.NewRecorder()59+ app.ServeHTTP(res, req)60+ if res.Code != http.StatusForbidden || strings.Contains(res.Body.String(), "private details") {61+ t.Fatalf("private error leaked: %d %s", res.Code, res.Body.String())62+ }63+}64+65+func TestChatActivityEndpointRejectsInvalidRoutesAndWrites(t *testing.T) {66+ app, _ := roomsApp(t, roomOwner)67+ for _, test := range []struct {68+ method, path string69+ status int70+ }{71+ {http.MethodPost, "/chat/activity?room=general", 405},72+ {http.MethodGet, "/chat/activity?room=../private", 400},73+ {http.MethodGet, "/chat/activity?room=general&root=bad", 400},74+ } {75+ t.Run(test.method+test.path, func(t *testing.T) {76+ res := httptest.NewRecorder()77+ app.ServeHTTP(res, httptest.NewRequest(test.method, test.path, nil))78+ if res.Code != test.status {79+ t.Fatalf("status %d", res.Code)80+ }81+ })82+ }83+}
A internal/webui/chat_activity_test.go +58 -0
@@ -0,0 +1,58 @@
1+package webui2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestChatActivityViewScopesDecisionsAndPreservesJobSummary(t *testing.T) {9+ actor := strings.Repeat("a", 64)10+ other := strings.Repeat("b", 64)11+ jobs := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("1", 64), "requester": other, "status": "working", "content": "Indexing files"}}}12+ approvals := map[string]any{"items": []any{13+ map[string]any{"id": strings.Repeat("2", 64), "asker": other, "asked": []any{actor}, "subject": "Publish result", "state": "open", "kind": "4550"},14+ map[string]any{"id": strings.Repeat("3", 64), "asker": other, "asked": []any{other}, "subject": "Private request", "state": "open", "kind": "4550"},15+ }}16+ items := chatActivityView(jobs, approvals, actor, "/chat/activity", "general")17+ if len(items) != 2 {18+ t.Fatalf("got %d activity items, want job and addressed approval", len(items))19+ }20+ if items[0].Type != "job" || items[0].Status != "working" || items[0].Title != "Indexing files" {21+ t.Fatalf("unexpected job card: %#v", items[0])22+ }23+ if items[1].Type != "approval" || !items[1].CanDecide || items[1].ID != strings.Repeat("2", 64) {24+ t.Fatalf("unexpected approval card: %#v", items[1])25+ }26+}27+28+func TestChatActivityViewDisablesAnsweredApproval(t *testing.T) {29+ actor := strings.Repeat("a", 64)30+ approvals := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("2", 64), "asker": actor, "asked": []any{actor}, "content": "Done", "state": "answered"}}}31+ items := chatActivityView(nil, approvals, actor, "/chat/activity", "general")32+ if len(items) != 1 || items[0].CanDecide {33+ t.Fatalf("answered approval remained actionable: %#v", items)34+ }35+}36+37+func TestChatActivityAskerCannotApproveOwnRequest(t *testing.T) {38+ actor := strings.Repeat("a", 64)39+ approvals := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("2", 64), "asker": actor, "asked": []string{actor, strings.Repeat("b", 64)}, "state": "open"}}}40+ items := chatActivityView(nil, approvals, actor, "/chat/activity", "main")41+ if len(items) != 1 || items[0].CanDecide {42+ t.Fatalf("asker allowed to decide: %#v", items)43+ }44+}45+46+func TestChatActivityPreservesAnsweredQuestion(t *testing.T) {47+ actor := strings.Repeat("a", 64)48+ other := strings.Repeat("b", 64)49+ approvals := map[string]any{"items": []any{map[string]any{50+ "id": strings.Repeat("4", 64), "asker": other, "asked": []string{actor},51+ "type": "question", "subject": "Which preview?", "state": "answered",52+ "answer": map[string]any{"decision": "replied", "content": "Use the dark preview."},53+ }}}54+ items := chatActivityView(nil, approvals, actor, "/chat/activity", "general")55+ if len(items) != 1 || items[0].Answer != "Use the dark preview." || items[0].CanDecide {56+ t.Fatalf("answered question = %#v", items)57+ }58+}
M internal/webui/chat_pages.go +4 -0
M internal/webui/components.js +2 -2
@@ -1667,7 +1667,7 @@
16671667 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;16681668 return root ? url.pathname.slice(root.length) || "/" : url.pathname;16691669 };1670- const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");1670+ const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|account|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");16711671 let navigationSerial = 0, activeAbort;16721672 const streams = new Set();16731673 const closeStreams = () => {
@@ -1682,7 +1682,7 @@
16821682 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));16831683 window.addEventListener("pagehide", closeStreams);16841684 const repairComponents = () => {1685- document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,social-compose,social-reaction,wiki-compose,room-compose,room-create,room-action").forEach(node => {1685+ document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,social-compose,social-reaction,wiki-compose,room-compose,room-create,room-action,chat-decision").forEach(node => {16861686 if (node.form?.isConnected) return;16871687 node.form = null;16881688 node.output = null;
A internal/webui/grant-request.js +64 -0
@@ -0,0 +1,64 @@
1+(() => {2+ "use strict";3+ const tiny = globalThis.tiny;4+ const stable = value => JSON.stringify(value, function(key,item) { return item && !Array.isArray(item) && typeof item === "object" ? Object.fromEntries(Object.keys(item).sort().map(name=>[name,item[name]])) : item; });5+ class GrantRequest extends HTMLElement {6+ connectedCallback() {7+ if (!this.onClick) {8+ this.onClick = event => {9+ const button = event.target.closest("[data-grant-approve], [data-grant-deny]");10+ if (button && this.contains(button)) this.decide(button, button.hasAttribute("data-grant-approve"));11+ };12+ this.addEventListener("click", this.onClick);13+ }14+ this.setBusy(false);15+ }16+ disconnectedCallback() { this.controller?.abort(); }17+ setBusy(busy) {18+ this.busy = busy;19+ this.querySelectorAll("button").forEach(button => {20+ button.disabled = busy || (button.hasAttribute("data-grant-approve") && this.hasAttribute("data-stale"));21+ });22+ }23+ report(text) {24+ let output = this.querySelector("output");25+ if (!output) { output = document.createElement("output"); output.setAttribute("role", "status"); this.append(output); }26+ output.textContent = text;27+ }28+ async latest(id) {29+ const query = new URLSearchParams({method: "browseapproval", params: JSON.stringify([{id}])});30+ const response = await fetch(tiny.localPath("/webmcp/query") + "?" + query, {credentials: "same-origin", cache: "no-store", signal: this.controller.signal});31+ const value = await response.json();32+ if (!response.ok || value?.error) throw Error(value?.error?.message || value?.error || "Could not refresh this request.");33+ return value.result || value;34+ }35+ async checkSigner(signer, operator) {36+ if (!signer?.getPublicKey || await signer.getPublicKey() !== operator || tiny.signer() !== signer) throw Error("Connect the requested operator's signer.");37+ if (!this.isConnected || this.controller.signal.aborted) throw Error("This request is no longer open.");38+ }39+ async decide(button, approve) {40+ if (this.busy) return;41+ this.setBusy(true);42+ this.controller = new AbortController();43+ try {44+ const review = JSON.parse(this.getAttribute("data-review") || "{}");45+ const signer = tiny.signer();46+ await this.checkSigner(signer, review.operator);47+ this.report("Checking current permissions…");48+ const {item} = await this.latest(review.request_id), fresh = item?.grant;49+ if (!item || item.id !== review.request_id || item.type !== "grant" || item.state !== "open" || item.asker !== review.agent || item.asked?.length !== 1 || item.asked[0] !== review.operator) throw Error("This request is no longer waiting for your decision.");50+ if (approve && (!fresh || item.grant_error || fresh.request_id !== review.request_id || fresh.base !== review.base || fresh.agent !== review.agent || fresh.operator !== review.operator || stable(fresh.before) !== stable(review.before) || stable(fresh.after) !== stable(review.after))) throw Error("Permissions changed. Reload and review this request again.");51+ await this.checkSigner(signer, review.operator);52+ const unsigned = approve ? fresh.unsigned : {kind: 7, created_at: Math.floor(Date.now()/1000), tags: [["e", review.request_id, "", review.agent], ["p", review.agent], ["k", "1111"]], content: "-"};53+ if (approve && (unsigned?.kind !== 30392 || unsigned.pubkey !== review.operator || !unsigned.tags.some(tag => tag[0] === "grant-request" && tag[1] === review.request_id) || !unsigned.tags.some(tag => tag[0] === "grant-base" && tag[1] === review.base))) throw Error("The grant review is incomplete.");54+ this.report(approve ? "Signing grant…" : "Signing denial…");55+ await tiny.signing.publish({kind:unsigned.kind,created_at:unsigned.created_at,tags:unsigned.tags,content:unsigned.content});56+ this.report(approve ? "Grant updated." : "Request denied.");57+ if (this.isConnected) await tiny.navigate(location.href);58+ } catch (error) {59+ if (this.isConnected) { this.report(error.message || String(error)); this.setBusy(false); }60+ }61+ }62+ }63+ customElements.define("agent-grant-request", GrantRequest);64+})();
M internal/webui/page.html +11 -3
@@ -49,6 +49,7 @@ then feature modules are loaded for the current page. */}}
4949 {{else if eq .Tab "rules"}}{{template "rules" .}}5050 {{else if eq .Tab "identity"}}{{template "identity" .}}5151 {{else if eq .Tab "signin"}}{{template "signin" .}}52+{{else if eq .Tab "account"}}{{template "account" .}}5253 {{else if eq .Tab "connect"}}{{template "connect" .}}5354 {{else if eq .Tab "data"}}{{template "data" .}}5455 {{else if eq .Tab "sync"}}{{template "sync" .}}
@@ -73,7 +74,7 @@ then feature modules are loaded for the current page. */}}
7374 {{end}}</div></td>7475 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>7576 </tr></tbody></table>76-<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrName" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="{{signinURL .Base .Path .Query}}">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table><button id="install" type="button" hidden>install app</button></footer>77+<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as <a href="/account">{{template "nostrName" .Actor}}</a> (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="{{signinURL .Base .Path .Query}}">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table><button id="install" type="button" hidden>install app</button></footer>7778 <script src="{{scriptURL "tiny.js"}}"></script>7879 <script src="{{scriptURL "bridge.js"}}"></script>7980 {{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>
@@ -88,6 +89,9 @@ then feature modules are loaded for the current page. */}}
8889 <script src="{{scriptURL "direct-messages.js"}}"></script>8990 <script src="{{scriptURL "chat-files.js"}}"></script>9091 <script src="{{scriptURL "chat.js"}}"></script>92+<script src="{{scriptURL "chat-presence.js"}}"></script>93+<script src="{{scriptURL "chat-activity.js"}}"></script>94+<script src="{{scriptURL "grant-request.js"}}"></script>9195 {{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script>9296 {{end}}</body>9397 </html>
@@ -109,7 +113,7 @@ then feature modules are loaded for the current page. */}}
109113 <nav id="room-list">{{range .Rooms}}{{$r := asMap .}}<a href="/rooms/{{str (index $r "id")}}"{{if eq (str (index $r "id")) $.View}} aria-current="page"{{end}}>{{or (str (index $r "name")) (str (index $r "id"))}}{{with age (index $r "last_message_at")}} <small>{{.}}</small>{{end}}</a>{{else}}<a href="/chat">Browse rooms</a>{{end}}</nav>110114 <p><a href="/chat#new-room">+ new room</a></p>111115 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>112-{{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}116+{{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}{{if .Actor}}<p id="personal-nav"><a href="/account"{{if eq .Tab "account"}} aria-current="page"{{end}}>account</a></p>{{end}}113117 {{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}114118 115119 {{/* The panel holds facts and filters for the current page. */}}
@@ -126,6 +130,7 @@ then feature modules are loaded for the current page. */}}
126130 {{else if eq .Tab "approvals"}}{{template "panel-approvals" .}}127131 {{else if or (eq .Tab "wiki") (eq .Tab "wikipage")}}{{template "panel-wiki" .}}128132 {{else if eq .Tab "signin"}}{{template "panel-signin" .}}133+{{else if eq .Tab "account"}}{{template "panel-account" .}}129134 {{else}}{{template "panel-relay" .}}130135 {{end}}{{end}}131136
@@ -136,6 +141,8 @@ then feature modules are loaded for the current page. */}}
136141 {{define "panel-signin"}}<section><h4>Session</h4><table><tr><th>status</th><td>{{if .Actor}}signed in{{else}}guest{{end}}</td></tr><tr><th>method</th><td>NIP-98</td></tr></table></section>137142 <section><h4>After sign in</h4><ul><li>join chats</li><li>manage members and policy</li><li>upload files</li><li>push repositories</li></ul></section>{{end}}138143 144+{{define "panel-account"}}<section><h4>Personal</h4><table><tr><th>identity</th><td>{{template "nostrKey" .Actor}}</td></tr><tr><th>profile</th><td><a href="/profile">edit</a></td></tr></table></section><section><h4>Relay</h4><p><small>Your relay lists and chat preferences apply to this account.</small></p></section>{{end}}145+139146 {{define "panel-repos"}}<section><h4>Hosting</h4><table><tr><th>server</th><td><code>{{.URL}}</code></td></tr><tr><th>protocol</th><td>GRASP-01</td></tr></table></section>140147 <section><h4>Publish</h4><p><small>Announce with ngit and push with plain git. Every push needs the owner's signed state.</small></p></section>{{end}}141148
@@ -167,8 +174,9 @@ then feature modules are loaded for the current page. */}}
167174 <section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/chat#new-room">new room</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in</a></li>{{end}}</ul></section>168175 {{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>169176 {{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$role := str (index $room "role")}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{$id}}</code></td></tr><tr><th>access</th><td>{{if eq (str (index $room "access")) "members"}}members only{{else}}open{{end}}</td></tr><tr><th>created</th><td>{{short (index $room "created_at")}}</td></tr>{{if $role}}<tr><th>you</th><td>{{$role}}</td></tr>{{end}}</table></section>177+<section><h4>Members</h4><ul id="members">{{range roomMembers .Event}}{{$m := asMap .}}<li{{if index $m "agent"}} data-agent{{with str (index $m "operator")}} data-operator="{{.}}"{{end}}{{end}}>{{template "nostrName" (str (index $m "pubkey"))}} <small>{{if index $m "agent"}}{{if index $m "operator"}}{{template "nostrName" (str (index $m "operator"))}}'s agent{{else}}agent{{end}}{{else}}{{str (index $m "role")}}{{end}}</small></li>{{else}}<li>{{str (index $room "members")}} members</li>{{end}}</ul></section>170178 {{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>171-{{else}}<section><h4>Members</h4><ul id="members">{{range roomMembers .Event}}{{$m := asMap .}}<li{{if index $m "agent"}} data-agent{{end}}>{{template "nostrName" (str (index $m "pubkey"))}} <small>{{str (index $m "role")}}{{if index $m "agent"}} | agent{{end}}</small></li>{{else}}<li>{{str (index $room "members")}} members</li>{{end}}</ul></section>179+{{else}}172180 {{if .Actor}}<section><h4>Actions</h4>{{if roomAdmin $role}}<details><summary>invite member</summary><room-action room="{{$id}}" kind="9000"><label>pubkey <input name="pubkey" placeholder="npub or 64 hex" required></label><label>role <select name="role"><option value="member">member</option><option value="admin">admin</option>{{if eq $role "owner"}}<option value="owner">owner</option>{{end}}</select></label><button>Add member</button></room-action></details>173181 <details><summary>room settings</summary><room-action room="{{$id}}" kind="9002"><label>name <input name="name" value="{{str (index $room "name")}}" required maxlength="200"></label><label>about <input name="about" value="{{str (index $room "about")}}" maxlength="2000"></label><label>picture <input name="picture" value="{{str (index $room "picture")}}" placeholder="https://"></label><label>access <select name="access"><option value="open"{{if ne (str (index $room "access")) "members"}} selected{{end}}>open</option><option value="members"{{if eq (str (index $room "access")) "members"}} selected{{end}}>members only</option></select></label><button>Save</button></room-action></details>174182 {{end}}{{if $role}}<room-action room="{{$id}}" kind="9022"><button>Leave room</button></room-action>{{else}}<room-action room="{{$id}}" kind="9021"><button>Join room</button></room-action>{{end}}<noscript><p>Room actions need JavaScript and a connected signer.</p></noscript></section>
M internal/webui/private.go +1 -1
M internal/webui/public.html +17 -14
@@ -29,26 +29,29 @@
2929 <h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1>3030 {{if .Notice}}<p role="status">{{.Notice}}</p>{{end}}3131 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>32-<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/chat">Chat</a></p>32+<p><a href="/account">Open your account</a> | <a href="/chat">Chat</a></p>3333 <p>Use the sign out control in the footer to end this session.</p>34-<details id="relay-setup"><summary>Your relay lists (optional)</summary>35-<p>Choose where other Nostr clients find your notes, messages and files. Add this relay to any lists you want to use. Existing entries stay.</p>34+{{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>35+{{template "signerControls" .}}36+{{end}}</section>37+{{end}}38+39+{{define "account"}}<section id="account">40+<h1>Account</h1>41+<p>Manage your profile, chat preferences and relay lists.</p>42+<table id="account-identity"><tbody><tr><th>signed in as</th><td>{{template "nostrName" .Actor}}</td></tr><tr><th>profile</th><td><a href="/profile">Edit your profile</a></td></tr></tbody></table>43+<h2>Chat preferences</h2>44+<chat-preferences actor="{{.Actor}}"><label><input type="checkbox" name="share_presence"{{if .SharePresence}} checked{{end}} disabled> Share presence and typing</label><p>Show when you are online or typing in chat.</p><output role="status"></output></chat-preferences><noscript><p>Changing preferences requires JavaScript and a connected signer.</p></noscript>45+<h2>Your relay lists</h2>46+<p>Choose where other Nostr clients find your notes, messages and files. Existing entries stay.</p>3647 <relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}">37-<table id="rows">38-<tbody>48+<table id="rows"><tbody>3949 <tr data-kind="10002" data-tag="r"><td>Relay list <small>NIP-65</small></td><td>where clients read your notes and send you mentions</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>4050 <tr data-kind="10050" data-tag="relay"><td>DM inbox <small>NIP-17</small></td><td>where people send you private messages</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>4151 <tr data-kind="10007" data-tag="relay"><td>Search relays <small>NIP-51</small></td><td>where clients run your searches</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>4252 <tr data-kind="10063" data-tag="server" data-value="server"><td>Blossom servers <small>BUD-03</small></td><td>where clients upload and look for your files</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>43-</tbody>44-</table>45-<output role="status"></output>46-</relay-lists>47-</details>48-{{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>49-{{template "signerControls" .}}50-{{end}}</section>51-{{end}}53+</tbody></table><output role="status"></output></relay-lists>54+</section>{{end}}5255 5356 {{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p>5457 <h2>Phone signer</h2>
M internal/webui/read_contracts.go +5 -4
M internal/webui/rooms.go +24 -18
@@ -6,8 +6,11 @@ import (
66 "html/template"77 "regexp"88 "sort"9+ "strconv"910 "strings"1011 "time"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/event"1114 )1215 1316 // roomPath is one rooms page: the list, one room, or one thread in a room.
@@ -123,12 +126,8 @@ func memberIndex(value any) map[string]map[string]any {
123126 func replyCounts(value any) map[string]int {124127 counts := map[string]int{}125128 for _, row := range roomSlice(value, "messages") {126- e := valueMap(row)127- if plainString(e["kind"]) != "12" {128- continue129- }130- if roots := tagValues(row, "e"); len(roots) > 0 {131- counts[roots[0]]++129+ if root := webRoomReplyRoot(row); root != "" {130+ counts[root]++132131 }133132 }134133 return counts
@@ -289,13 +288,13 @@ func clock(value any) string {
289288 // roomItem is one message as the page shows it: the event's fields, the290289 // author's markers, the thread state and the reactions it has received.291290 type roomItem struct {292- ID, PubKey, Avatar, Kind, Content, Room, Root, Quote, Role, Notice string293- CreatedAt, UpdatedAt int64294- Agent, InThread, Edited, Own bool295- Mentions []string296- Replies int297- Reactions []reaction298- Attachments []roomAttachment291+ ID, PubKey, Avatar, Kind, Content, Room, Root, Quote, Role, Operator, Notice string292+ CreatedAt, UpdatedAt int64293+ Agent, InThread, Edited, Own bool294+ Mentions []string295+ Replies int296+ Reactions []reaction297+ Attachments []roomAttachment299298 }300299 301300 // roomItems turns a browse result's messages or replies into view items,
@@ -312,7 +311,7 @@ func roomItems(value any, field string, actors ...string) []roomItem {
312311 items := make([]roomItem, 0, len(rows))313312 for i := len(rows) - 1; i >= 0; i-- {314313 item, ok := newRoomItem(rows[i], room, members)315- if !ok {314+ if !ok || field == "messages" && item.Root != "" {316315 continue317316 }318317 // Only the author edits their own message, and only forward in time.
@@ -374,10 +373,8 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI
374373 if item.Kind == "44101" {375374 item.Notice = "left the room"376375 }377- case "12":378- if roots := tagValues(row, "e"); len(roots) > 0 {379- item.Root = roots[0]380- }376+ case "9", "12":377+ item.Root = webRoomReplyRoot(row)381378 }382379 if len(item.PubKey) >= 2 {383380 item.Avatar = strings.ToUpper(item.PubKey[:2])
@@ -385,6 +382,7 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI
385382 if member := members[item.PubKey]; member != nil {386383 item.Role = plainString(member["role"])387384 item.Agent, _ = member["agent"].(bool)385+ item.Operator = plainString(member["operator"])388386 }389387 if item.Notice == "" {390388 for _, mention := range tagValues(row, "p") {
@@ -401,3 +399,11 @@ func roomAdmin(role any) bool {
401399 value := plainString(role)402400 return value == "owner" || value == "admin"403401 }402+403+func webRoomReplyRoot(row any) string {404+ kind, err := strconv.Atoi(plainString(valueMap(row)["kind"]))405+ if err != nil {406+ return ""407+ }408+ return event.RoomReplyRoot(event.Event{Kind: kind, Tags: roomTags(row)})409+}
M internal/webui/rooms.html +9 -3
@@ -31,10 +31,12 @@ establishes the initial state. */}}
3131 {{with str (index $room "about")}}<p>{{.}}</p>{{end}}3232 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}3333 {{if .Query.Get "cursor"}}<p id="newest"><a href="/rooms/{{$id}}">newest messages</a></p>{{end}}34+{{template "chatActivity" .Activity}}3435 <div id="messages">{{range roomItems .Event "messages" .Actor}}3536 {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}3637 </div>3738 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>39+{{template "chatPresence" .}}3840 {{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Message" placeholder="Message…" title="Mention someone with @npub or @hex" required></textarea><button type="submit" aria-label="Send" title="Send message">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>3941 <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>4042 {{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to send messages.</p>
@@ -49,12 +51,14 @@ establishes the initial state. */}}
4951 <p id="crumbs"><a href="/rooms/{{$id}}">{{or (str (index $room "name")) $id}}</a></p>5052 <h1>Thread <small>{{$root.Replies}} {{if eq $root.Replies 1}}reply{{else}}replies{{end}}</small></h1>5153 <div id="root">{{template "roomMessage" $root}}</div>54+{{template "chatActivity" .Activity}}5255 <div id="messages">{{range roomItems .Event "replies" .Actor}}5356 {{template "roomMessage" .}}{{else}}<p id="empty">No replies yet.</p>{{end}}5457 </div>5558 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}5659 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>57-{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="12" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Reply" placeholder="Reply in this thread…" required></textarea><button type="submit" aria-label="Reply" title="Send reply">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>60+{{template "chatPresence" .}}61+{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="{{if eq $root.Kind "11"}}12{{else}}9{{end}}" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Reply" placeholder="Reply in this thread…" required></textarea><button type="submit" aria-label="Reply" title="Send reply">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>5862 <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>5963 {{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to reply.</p>6064 {{end}}{{end}}</section>
@@ -65,7 +69,9 @@ establishes the initial state. */}}
6569 6670 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}6771 {{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-updated-at="{{.UpdatedAt}}" data-pubkey="{{.PubKey}}"{{if .Own}} data-own{{end}}{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}><nostr-avatar pubkey="{{.PubKey}}" aria-hidden="true">{{.Avatar}}</nostr-avatar>68-<header><b>{{template "nostrName" .PubKey}}</b>{{if .Own}}<span data-own-label>you</span>{{end}}{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>72+<header><b>{{template "nostrName" .PubKey}}</b>{{if .Own}}<span data-own-label>you</span>{{end}}{{if .Agent}} | {{if .Operator}}{{template "nostrName" .Operator}}'s agent{{else}}agent{{end}}{{else if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>6973 {{if .Notice}}<p>{{.Notice}}</p>{{else}}{{if .Quote}}<blockquote data-quote><a href="/open?target={{urlquery (printf "nostr:%s" .Quote)}}">quoted event {{shortID .Quote}}</a></blockquote>{{end}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}}70-{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}}74+{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") (eq .Kind "9") (eq .Kind "40002") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if or (eq .Kind "11") (eq .Kind "9") (eq .Kind "40002")}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}}7175 </room-message>{{end}}76+77+{{define "chatPresence"}}<chat-presence room="{{.View}}" actor="{{.Actor}}"><output aria-live="polite"></output></chat-presence>{{end}}
M internal/webui/rooms.js +44 -10
@@ -325,10 +325,23 @@
325325 const members = {};326326 document.querySelectorAll("#members li").forEach(item => {327327 const hex = item.querySelector("nostr-name")?.getAttribute("pubkey") || item.querySelector("nostr-key")?.getAttribute("hex");328- if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent")};328+ if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent"), operator: item.getAttribute("data-operator") || ""};329329 });330330 return members;331331 };332+ const replyRoot = event => {333+ if (![9, 12].includes(event?.kind)) return "";334+ let root = "", reply = "", first = "", marked = false;335+ for (const tag of event.tags || []) {336+ if (tag[0] !== "e" || !isHex64(tag[1])) continue;337+ const marker = tag[3] || "";338+ marked ||= Boolean(marker);339+ if (marker === "root") root = tag[1];340+ else if (marker === "reply") reply = tag[1];341+ else if (!marker && !first) first = tag[1];342+ }343+ return root || reply || (!marked ? first : "");344+ };332345 const messageNode = (event, {members = {}, room = "", inThread = false} = {}) => {333346 const node = el("room-message");334347 const notice = event.kind === 44100 ? "joined the room" : event.kind === 44101 ? "left the room" : "";
@@ -354,7 +367,12 @@
354367 small.append(time);355368 header.append(name);356369 if (own) { const label = el("span", "you"); label.dataset.ownLabel = ""; header.append(label); }357- header.append(member?.role ? " | " + member.role : "", small);370+ if (member?.agent) {371+ header.append(" | ");372+ if (member.operator) header.append(nameNode(member.operator), "'s agent");373+ else header.append("agent");374+ } else if (member?.role) header.append(" | " + member.role);375+ header.append(small);358376 const attachments = notice ? [] : roomAttachments(event);359377 const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments));360378 node.append(avatar, header);
@@ -365,9 +383,9 @@
365383 const footer = el("footer");366384 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);367385 if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(nameNode(key), " ")); footer.append(span); }368- if (!inThread && room && event.kind === 11) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); }369- const root = tagValue(event, "e");370- if (!inThread && room && event.kind === 12 && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); }386+ if (!inThread && room && [9, 11, 40002].includes(event.kind)) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); }387+ const root = replyRoot(event);388+ if (!inThread && room && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); }371389 if (footer.childNodes.length) node.append(footer);372390 return node;373391 };
@@ -388,6 +406,16 @@
388406 const roomAppend = (event, {room = "", inThread = false, own = false} = {}) => {389407 const list = document.getElementById("messages");390408 if (!list || !isHex64(event?.id) || document.getElementById("msg-" + event.id)) return null;409+ list.roomSeen ||= new Set();410+ if (list.roomSeen.has(event.id)) return null;411+ list.roomSeen.add(event.id);412+ while (list.roomSeen.size > 2000) list.roomSeen.delete(list.roomSeen.values().next().value);413+ const reference = replyRoot(event);414+ if (reference && !inThread) {415+ const link = document.querySelector("#msg-" + reference + " > footer > a");416+ if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); }417+ return null;418+ }391419 const follow = own || roomNearBottom();392420 list.querySelector("#empty")?.remove();393421 const node = messageNode(event, {members: panelMembers(), room, inThread});
@@ -575,10 +603,10 @@
575603 const kind = Number(this.getAttribute("kind") || 9);576604 if (kind !== 9 && kind !== 12) throw Error("Unsupported message kind.");577605 const tags = [["h", room]];578- if (kind === 12) {606+ if (kind === 12 || this.getAttribute("root")) {579607 const root = this.getAttribute("root"), author = this.getAttribute("root-pubkey");580608 if (!isHex64(root)) throw Error("The thread root is missing.");581- tags.push(["e", root]);609+ tags.push(kind === 12 ? ["e", root] : ["e", root, "", "root"]);582610 if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);583611 }584612 const quote = this.getAttribute("quote");
@@ -650,7 +678,7 @@
650678 this.renderFiles();651679 this.resizeContent();652680 this.report("");653- roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});681+ roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(this.getAttribute("root")), own: true});654682 } finally { this.uploadController = null; }655683 }656684 }
@@ -760,16 +788,22 @@
760788 761789 receive(event) {762790 if (!event || typeof event !== "object") return;791+ const room = this.getAttribute("room");792+ if (tagValue(event, "h") !== room && !(event.kind === 20001 && !tagValue(event, "h"))) return;793+ if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:room-event", {detail: {room, event}}));763794 if (event.kind === 7) { roomReact(event); return; }764795 if (event.kind === 40003) { roomEdit(event); return; }765796 if (!roomKinds.includes(event.kind)) return;766797 const root = this.getAttribute("root");767- if (root && (event.kind !== 12 || tagValue(event, "e") !== root)) return;798+ if (root) {799+ const reference = replyRoot(event);800+ if (reference !== root && !document.getElementById("msg-" + reference)) return;801+ }768802 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});769803 }770804 }771805 772- tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes});806+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, replyRoot, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes});773807 tiny.roomElements = Object.freeze({RoomCompose, RoomCreate, RoomAction, RoomLive});774808 customElements.define("room-compose", RoomCompose);775809 customElements.define("room-create", RoomCreate);
M internal/webui/rooms_test.go +3 -4
@@ -66,7 +66,7 @@ func (b *roomsBackend) Query(_ context.Context, method string, params []json.Raw
6666 default:6767 return nil, nil6868 }69- members := []any{map[string]any{"pubkey": roomOwner, "role": "owner", "added_at": 1757200000}, map[string]any{"pubkey": roomAgent, "role": "member", "added_at": 1757200100, "agent": true}}69+ members := []any{map[string]any{"pubkey": roomOwner, "role": "owner", "added_at": 1757200000}, map[string]any{"pubkey": roomAgent, "role": "member", "added_at": 1757200100, "agent": true, "operator": roomOwner}}7070 root := roomEvent(roomThread, roomAgent, 11, 1757203000, "Release notes draft", []string{"h", id})7171 reply := roomEvent(roomReply, roomOwner, 12, 1757203300, "Looks good", []string{"h", id}, []string{"e", roomThread}, []string{"p", roomAgent})7272 if method == "browsethread" {
@@ -156,11 +156,10 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
156156 `<span data-reaction="&#43;1">&#43;1 1</span>`,157157 `<div><p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`,158158 `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 reply</a>`,159- `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`,160159 `<room-live room="general"></room-live>`,161160 `<room-compose room="general" pubkey="` + roomOwner + `">`,162161 `<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>`,163- `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent><nostr-name pubkey="` + roomAgent + `"`, `<small>member | agent</small>`,162+ `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent data-operator="` + roomOwner + `"><nostr-name pubkey="` + roomAgent + `"`, `<small><nostr-name pubkey="` + roomOwner + `" title="` + roomOwner + `">` + shortID(roomOwner) + `</nostr-name>'s agent</small>`,164163 `<room-action room="general" kind="9000">`, `<room-action room="general" kind="9002">`, `<room-action room="general" kind="9022">`,165164 `<nav id="room-list"><a href="/rooms/general" aria-current="page">General`,166165 `&raquo; <page-link url="http://relay.example/rooms/general" title="Copy the page address">rooms/general</page-link></span>`,
@@ -170,7 +169,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
170169 }171170 }172171 hello, chat, thread, reply := strings.Index(body, `id="msg-`+roomHello), strings.Index(body, `id="msg-`+roomChat), strings.Index(body, `id="msg-`+roomThread), strings.Index(body, `id="msg-`+roomReply)173- if !(hello < chat && chat < thread && thread < reply) {172+ if !(hello < chat && chat < thread && reply == -1) {174173 t.Fatalf("messages are not oldest first: %d %d %d %d", hello, chat, thread, reply)175174 }176175 if strings.Contains(body, `data-kind="7"`) || strings.Contains(body, "<script>alert") {
M internal/webui/shell_test.go +16 -3
@@ -309,7 +309,12 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
309309 }310310 }311311 312-type connectionsBackend struct{ fakeBackend }312+type connectionsBackend struct {313+ fakeBackend314+ sharePresence bool315+}316+317+func (b *connectionsBackend) SharePresence(context.Context, string) bool { return b.sharePresence }313318 314319 func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {315320 if method == "connections" {
@@ -319,19 +324,27 @@ func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.Ra
319324 }320325 321326 func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) {322- backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}327+ backend := &connectionsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, sharePresence: true}323328 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})324329 if err != nil {325330 t.Fatal(err)326331 }327332 recorder := httptest.NewRecorder()328- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))333+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/account", nil))329334 body := recorder.Body.String()330335 for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`} {331336 if !strings.Contains(body, want) {332337 t.Errorf("account page missing %q", want)333338 }334339 }340+ if !strings.Contains(body, `<input type="checkbox" name="share_presence" checked disabled>`) {341+ t.Error("account page did not show saved presence preference")342+ }343+ recorder = httptest.NewRecorder()344+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))345+ if strings.Contains(recorder.Body.String(), "<relay-lists") {346+ t.Fatal("sign-in page still exposes account relay settings")347+ }335348 if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `customElements.define("relay-lists"`) {336349 t.Error("components script missing relay-lists")337350 }
M internal/webui/style.css +33 -1
@@ -324,7 +324,6 @@ room-message>div pre{margin:0 0 6px;padding:6px 8px;background:var(--rail);borde
324324 room-message>[data-room-attachments]{margin-top:8px}room-message>[data-room-attachments] figure{display:inline-block;vertical-align:top;margin:0 12px 12px 0;max-width:100%;min-width:0}room-message>[data-room-attachments] img{display:block;width:auto;max-width:100%;max-height:360px;border:1px solid var(--line);object-fit:contain}room-message>[data-room-attachments] video{display:block;width:420px;max-width:100%;max-height:360px}room-message>[data-room-attachments] audio{display:block;width:360px;max-width:100%}room-message>[data-room-attachments] figcaption{margin-top:4px;font:12px var(--mono);overflow-wrap:anywhere}room-message>[data-room-attachments] a[data-download]{color:var(--accent)}room-message>[data-room-attachments] small{color:var(--muted);font:11px var(--mono)}325325 room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px}326326 room-message>footer>*+*::before{content:" | "}327-room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px}328327 room-message[data-notice]>p{color:var(--muted);font-size:13px}329328 room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:right}room-live:empty{display:none}330329 room-compose{display:block;position:sticky;bottom:0;margin:8px -22px 0;padding:10px 22px 8px;background:var(--paper);border-top:1px solid var(--line)}
@@ -522,3 +521,36 @@ direct-file article{padding:0;margin:0;border:0}
522521 direct-file button{display:block;margin:8px 0;padding:8px 12px;min-height:40px}523522 direct-file output,direct-settings output{display:block;font-size:12px;color:var(--muted);margin-top:6px}524523 direct-file pre{max-height:360px;overflow:auto;padding:10px;background:var(--rail);font:13px/1.5 var(--mono)}524+525+/* Chat activity stays compact; details and decisions share the room width. */526+chat-activity,chat-presence{display:block;min-width:0}527+chat-activity:empty{display:none}528+chat-activity>article{display:block;margin:10px 0;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--rail);overflow-wrap:anywhere}529+chat-activity header,chat-activity summary{font:13px/1.6 var(--sans)}530+chat-activity header small,chat-activity summary small{font:11px var(--mono);color:var(--muted);margin-left:8px}531+chat-activity p{margin:6px 0;font:13px/1.5 var(--sans)}532+chat-activity details>div{font:13px/1.5 var(--sans)}533+chat-activity details>div pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:8px;background:var(--paper)}534+chat-activity [data-status=processing],chat-activity [data-status=partial]{color:var(--bark)}535+chat-activity [data-status=success]{color:var(--accent)}536+chat-activity [data-status=error]{color:var(--alert)}537+chat-decision{display:block;margin-top:10px}chat-decision button{margin-right:8px;min-height:36px}538+chat-decision textarea{display:block;width:100%;min-width:0;max-width:100%;margin:6px 0}539+chat-decision output{display:block;font:12px var(--mono);margin-top:6px;color:var(--muted)}540+chat-decision output[data-error]{color:var(--alert)}541+chat-presence{padding:4px 0;color:var(--muted);font:11px/1.6 var(--mono)}542+chat-presence output{display:block;min-height:18px;overflow-wrap:anywhere}543+544+chat-activity blockquote{margin:8px 0;padding:6px 10px;border-left:2px solid var(--accent);font:13px/1.5 var(--sans);white-space:pre-wrap}545+546+chat-presence output>span+span::before{content:" | "}547+548+chat-preferences{display:block;margin:12px 0}549+chat-preferences label{display:block;text-transform:none;letter-spacing:0}550+chat-preferences output{display:block;color:var(--muted);font:12px var(--mono);margin:6px 0}551+552+agent-grant-request { display: block; min-width: 0; }553+agent-grant-request table { display: table; table-layout: fixed; width: 100%; margin: 1rem 0; }554+agent-grant-request th, agent-grant-request td { white-space: normal; overflow-wrap: anywhere; text-align: left; vertical-align: top; padding: .5rem .4rem; }555+agent-grant-request th:first-child { width: 24%; }556+agent-grant-request output { display: block; margin-top: .5rem; }
M internal/webui/templates.go +11 -2
@@ -16,7 +16,7 @@ import (
1616 // templateFS holds every page template. page.html owns the shell and the1717 // shared partials; the other files each define one group of tabs.1818 //19-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html chat.html19+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html chat.html chat-activity.html2020 var templateFS embed.FS2121 2222 // styleCSS is inlined into every page so the UI needs no extra request and
@@ -41,6 +41,15 @@ var componentsJS string
4141 //go:embed rooms.js4242 var roomsJS string4343 44+//go:embed chat-presence.js45+var chatPresenceJS string46+47+//go:embed chat-activity.js48+var chatActivityJS string49+50+//go:embed grant-request.js51+var grantRequestJS string52+4453 //go:embed chat.js4554 var chatJS string4655
@@ -241,7 +250,7 @@ func repoView(query url.Values) string {
241250 // scripts are served at /scripts/<name> and cached by the service worker.242251 // One version stamp covers them all, so a change to any file refreshes every243252 // cached copy together.244-var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "rooms.js": roomsJS, "chat.js": chatJS, "chat-files.js": chatFilesJS, "direct-messages.js": directMessagesJS, "nostr-name.js": nostrNameJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS, "file-catalog.js": fileCatalogJS}253+var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "rooms.js": roomsJS, "chat.js": chatJS, "chat-presence.js": chatPresenceJS, "chat-activity.js": chatActivityJS, "grant-request.js": grantRequestJS, "chat-files.js": chatFilesJS, "direct-messages.js": directMessagesJS, "nostr-name.js": nostrNameJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS, "file-catalog.js": fileCatalogJS}245254 246255 var scriptsVersion = func() string {247256 names := make([]string, 0, len(scripts))
M internal/webui/webui.go +42 -24
@@ -43,6 +43,12 @@ type Backend interface {
4343 Identity() string4444 }4545 46+// AccountPreferencesReader provides private, account-scoped preferences to47+// server-rendered account pages. It is optional for small embedders.48+type AccountPreferencesReader interface {49+ SharePresence(context.Context, string) bool50+}51+4652 // CustomViewSource is the optional backend summary of the custom views the4753 // renderers show in place of fenced code blocks: names and languages only.4854 type CustomViewSource interface {
@@ -73,29 +79,31 @@ type Options struct {
7379 }7480 7581 type PageData struct {76- Version string77- Revision string78- Title string79- URL string80- Slug string81- Identity string82- Policy policy.Policy83- Actor string84- Owner bool85- Tab string86- Notice string87- Error string88- Methods []string89- Event any90- Invite string91- View string92- Feed []any93- Base string94- Query url.Values95- Private bool96- Path string97- Readme template.HTML98- Tree []any82+ Activity chatActivityPage83+ SharePresence bool84+ Version string85+ Revision string86+ Title string87+ URL string88+ Slug string89+ Identity string90+ Policy policy.Policy91+ Actor string92+ Owner bool93+ Tab string94+ Notice string95+ Error string96+ Methods []string97+ Event any98+ Invite string99+ View string100+ Feed []any101+ Base string102+ Query url.Values103+ Private bool104+ Path string105+ Readme template.HTML106+ Tree []any99107 // Merge is the browsewikimerge detail shown by a wiki page compare view.100108 Merge any101109 // Connections lists the ways to open this relay in client apps that the
@@ -642,6 +650,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
642650 data.Rooms = data.Feed643651 case "room", "thread":644652 data.Rooms = a.roomList(request.Context(), actor)653+ data.Activity = a.chatActivity(request.Context(), actor, room.id, plainString(valueMap(valueMap(result)["root"])["id"]))645654 }646655 if method == "browserepo" && pageQuery.Get("view") == "home" {647656 data.Readme = a.readme(request.Context(), actor, pageQuery)
@@ -774,6 +783,11 @@ func (a *App) render(writer http.ResponseWriter, request *http.Request, data Pag
774783 }775784 data.URL, data.Slug, data.Identity, data.Policy, data.Actor, data.Base = a.backend.URL(), a.backend.Slug(), a.backend.Identity(), a.backend.Policy(), actor, requestPrefix(request)776785 data.Owner = actor != "" && actor == data.Policy.Owner786+ if actor != "" && data.Tab == "account" {787+ if preferences, ok := a.backend.(AccountPreferencesReader); ok {788+ data.SharePresence = preferences.SharePresence(request.Context(), actor)789+ }790+ }777791 data.Version, data.Revision = a.version, a.revision778792 data.Methods = supportedMethods779793 if data.Query == nil {
@@ -781,13 +795,15 @@ func (a *App) render(writer http.ResponseWriter, request *http.Request, data Pag
781795 }782796 data.Path = strings.TrimPrefix(request.URL.Path, data.Base)783797 a.privatePageData(&data, request, actor)784- if !data.Private && data.Actor == "" && (railKind(data.Tab) == "manage" || data.Tab == "profile") {798+ if !data.Private && data.Actor == "" && (railKind(data.Tab) == "manage" || data.Tab == "profile" || data.Tab == "account") {785799 // Management pages and the profile editor are for signed-in people786800 // only. Guests see the sign-in page at the same address and come back787801 // after signing in.788802 data.Tab, data.Notice = "signin", "Sign in to manage this relay."789803 if data.Path == "/profile" {790804 data.Notice = "Sign in to edit your profile."805+ } else if data.Path == "/account" {806+ data.Notice = "Sign in to open your account."791807 }792808 data.Event, data.Feed, data.Error = nil, nil, ""793809 }
@@ -1267,6 +1283,8 @@ func tabForPath(path string) string {
12671283 return "approvals"12681284 case "profile":12691285 return "profile"1286+ case "account":1287+ return "account"12701288 case "signin", "sites":12711289 return path12721290 case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":
A scripts/chat-activity.test.mjs +92 -0
@@ -0,0 +1,92 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+6+const source = fs.readFileSync("internal/webui/chat-activity.js", "utf8");7+const actor = "a".repeat(64), author = "b".repeat(64), request = "c".repeat(64);8+9+class Node {10+ constructor(tag = "div") { this.localName = tag; this.childNodes = []; this.attributes = {}; this.parent = null; }11+ get isConnected() { return Boolean(this.parent); }12+ append(...items) { for (const item of items) { if (item instanceof Node) item.parent = this; this.childNodes.push(item); } }13+ replaceChildren(...items) { this.childNodes = []; this.append(...items); }14+ setAttribute(name, value) { this.attributes[name] = String(value); }15+ getAttribute(name) { return this.attributes[name] ?? null; }16+ hasAttribute(name) { return Object.hasOwn(this.attributes, name); }17+ querySelectorAll() { return []; }18+ contains(node) { return node === this || this.childNodes.some(child => child instanceof Node && child.contains(node)); }19+ closest(selector) { return selector === this.localName ? this : this.parent?.closest(selector) || null; }20+ dispatchEvent(event) { this.listeners?.[event.type]?.(event); return true; }21+ addEventListener(name, fn) { (this.listeners ||= {})[name] = fn; }22+ removeEventListener(name) { delete this.listeners?.[name]; }23+}24+25+function setup() {26+ const document = new Node("document"); document.hidden = false; document.activeElement = null;27+ const definitions = {}, events = {};28+ document.addEventListener = (name, fn) => (events[name] ||= []).push(fn);29+ document.removeEventListener = () => {};30+ const signer = {getPublicKey: async () => actor};31+ const published = [];32+ const tiny = {signer: () => signer, localPath: path => path, signing: {publish: async event => { published.push(event); return event; }},33+ ui: {FormElement: class extends Node { report(message) { this.message = message; } connectedCallback() { this.form = {}; } }}};34+ const template = new Node("template");35+ document.createElement = tag => tag === "template" ? template : new Node(tag);36+ const sandbox = {document, HTMLElement: Node, customElements: {define: (name, ctor) => definitions[name] = ctor}, tiny,37+ location: {href: "https://relay.test/rooms/room", origin: "https://relay.test"}, URL, AbortController, setInterval: () => 1,38+ clearInterval: () => {}, setTimeout, clearTimeout, queueMicrotask, CustomEvent: class { constructor(type,opts){this.type=type;Object.assign(this,opts);} }, morph: (target,html)=>{target.morphed=html;}, fetch: async () => ({ok: true, text: async () => ""}), Date, JSON, Error};39+ vm.runInNewContext(source, sandbox);40+ return {document, definitions, tiny, signer, published, template};41+}42+43+test("Fixi refresh preserves a draft focused while the request was in flight", async () => {44+ const {definitions, document} = setup();45+ const host = new (definitions["chat-activity"])(); host.parent = document;46+ const original = new Node("textarea"); host.append(original); host.connectedCallback();47+ const cfg={trigger:{target:host},headers:{},abort(){},response:{ok:true},target:new Node(),text:"new cards"};48+ host.onConfig({target:host,detail:{cfg}});49+ assert.equal(cfg.cache,"no-store");assert.equal(cfg.credentials,"same-origin");assert.equal(cfg.transition,false);50+ document.activeElement=original;cfg.swap();assert.equal(cfg.target.morphed,undefined);51+ document.activeElement=null;cfg.swap();assert.equal(cfg.target.morphed,"new cards");52+ host.disconnectedCallback();53+});54+55+test("Fixi swap preserves details and ignores failed responses and logout", () => {56+ const {definitions,document}=setup(),host=new (definitions["chat-activity"])();host.parent=document;57+ const open={dataset:{task:"first"}},first={dataset:{task:"first"}},second={dataset:{task:"second"}};58+ host.querySelectorAll=()=>[open];59+ const target={querySelectorAll:selector=>selector.startsWith("details")?[first,second]:[]};60+ const cfg={target,text:"cards",response:{ok:false}};host.swap(cfg);assert.equal(target.morphed,undefined);61+ cfg.response.ok=true;host.swap(cfg);assert.equal(first.open,true);assert.equal(second.open,false);62+ host.stopped=true;cfg.text="logout";host.swap(cfg);assert.equal(target.morphed,"cards");63+});64+65+test("decision revalidates the current card before signing and emits a NIP-22 answer", async () => {66+ const {definitions, document, template, published} = setup();67+ const Decision = definitions["chat-decision"], host = new (definitions["chat-activity"])(); host.localName = "chat-activity"; host.parent = document;68+ host.read = async () => "fresh"; host.schedule = () => { host.scheduled = true; };69+ const allowed = new Node("chat-decision"); allowed.setAttribute("event", request); allowed.setAttribute("actor", actor); allowed.setAttribute("pubkey", author);70+ template.content = {querySelectorAll: selector => selector === "chat-decision" ? [allowed] : []};71+ const decision = new Decision(); decision.localName = "chat-decision"; decision.parent = host;72+ for (const [name, value] of [["event", request], ["actor", actor], ["pubkey", author], ["room", "room"], ["kind", "5000"], ["question", ""]]) decision.setAttribute(name, value);73+ decision.submitter = {value: "?"};74+ const form = {elements: {content: {value: " answer "}}};75+ await decision.submit(form);76+ assert.equal(published.length, 1);77+ assert.equal(published[0].kind, 1111);78+ assert.deepEqual(JSON.parse(JSON.stringify(published[0].tags)), [["h", "room"], ["E", request, "", author], ["K", "5000"], ["P", author], ["e", request, "", author], ["k", "5000"], ["p", author]]);79+ assert.equal(published[0].content, "answer");80+});81+82+test("decision refuses a card that disappeared while it was being answered", async () => {83+ const {definitions, document, template} = setup();84+ const Decision = definitions["chat-decision"], host = new (definitions["chat-activity"])(); host.localName = "chat-activity"; host.parent = document;85+ host.read = async () => "gone"; host.schedule = () => { host.scheduled = true; };86+ template.content = {querySelectorAll: () => []};87+ const decision = new Decision(); decision.localName = "chat-decision"; decision.parent = host;88+ for (const [name, value] of [["event", request], ["actor", actor], ["pubkey", author], ["room", "room"], ["kind", "5000"]]) decision.setAttribute(name, value);89+ decision.submitter = {value: "+"};90+ await assert.rejects(decision.submit({elements: {}}), /no longer waiting/);91+ assert.equal(host.scheduled, true);92+});
A scripts/chat-presence.test.mjs +146 -0
@@ -0,0 +1,146 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+6+const source = fs.readFileSync("internal/webui/chat-presence.js", "utf8");7+const key = "a".repeat(64), other = "b".repeat(64);8+9+class Node {10+ constructor(tag = "div") { this.localName = tag; this.childNodes = []; this.attributes = {}; this.parent = null; }11+ get isConnected() { return Boolean(this.parent); }12+ append(...items) { for (const item of items) { if (item instanceof Node) { item.parent = this; } this.childNodes.push(item); } }13+ replaceChildren(...items) { this.childNodes = []; this.append(...items); }14+ get textContent() { return this.childNodes.map(item => item instanceof Node ? item.textContent : String(item)).join(""); }15+ set textContent(value) { this.childNodes = value ? [String(value)] : []; }16+ setAttribute(name, value) { this.attributes[name] = String(value); }17+ getAttribute(name) { return this.attributes[name] ?? null; }18+ querySelector(selector) { return this.childNodes.find(item => item.localName === selector) || null; }19+ get children() { return this.childNodes.filter(item => item instanceof Node); }20+ remove() { if(this.parent) this.parent.childNodes = this.parent.childNodes.filter(item=>item!==this); this.parent=null; }21+ insertBefore(item,before) { item.remove(); const index=before ? this.childNodes.indexOf(before) : this.childNodes.length; this.childNodes.splice(index,0,item); item.parent=this; }22+ addEventListener(name, fn) { (this.listeners ||= {})[name] = fn; }23+ removeEventListener(name) { delete this.listeners?.[name]; }24+ matches(selector) { return selector === this.localName; }25+ appendChild(item) { this.append(item); return item; }26+}27+28+function setup(options={}) {29+ const document = new Node("document");30+ document.hidden = false;31+ document.createElement = tag => new Node(tag);32+ document.addEventListener = (name, fn) => (document.listeners ||= {})[name] = fn;33+ document.removeEventListener = name => delete document.listeners?.[name];34+ const definitions = {}, saved = [], dispatched = [];35+ document.dispatchEvent = event => dispatched.push(event);36+ const tiny = {signedFetch: async (path,method,body,settings) => {saved.push({path,method,body,settings}); if(options.saveError) throw Error(options.saveError); return {json:async()=>JSON.parse(body)};},localPath:path=>path,signer: () => signer, signing: {publish: async event => published.push(event)}};37+ const published = [];38+ const signer = {getPublicKey: async () => key, signEvent: async () => ({})};39+ class HTMLElement extends Node {}40+ const sandbox = {document, HTMLElement, customElements: {define: (name, ctor) => definitions[name] = ctor}, tiny,41+ AbortController,CustomEvent:class {constructor(type,options){this.type=type;this.detail=options.detail;}},fetch:options.fetch || (async()=>({ok:true,json:async()=>({share_presence:options.share === true})})),setInterval: () => 1, clearInterval: () => {}, setTimeout, clearTimeout, Date, JSON, String, Number, Boolean, Set, Map, Object};42+ vm.runInNewContext(source, sandbox);43+ return {document, definitions, tiny, published, signer, saved, dispatched};44+}45+46+const event = (kind, pubkey, created_at, tags, content = "online") => ({kind, pubkey, created_at, tags, content});47+48+test("presence parser enforces room scope, TTL and typing precedence", () => {49+ const {tiny} = setup();50+ const {parse, PRESENCE, TYPING} = tiny.roomPresence;51+ assert.equal(parse(event(PRESENCE, other, 100, [["h", "other"]]), "room", 100), null);52+ assert.equal(parse(event(TYPING, other, 100, [], "typing"), "room", 100), null);53+ assert.equal(parse(event(PRESENCE, other, 100, [["h", "room"]]), "room", 281), null);54+ assert.equal(parse(event(TYPING, other, 100, [["h", "room"]], "typing"), "room", 106), null);55+ assert.equal(parse(event(PRESENCE, other, 100, [], '{"status":"away"}'), "room", 100).status, "away");56+ assert.equal(parse(event(TYPING, other, 100, [["h", "room"]], '{"typing":false}'), "room", 100).status, "stopped");57+});58+59+test("presence ignores stale events and keeps typing ahead of presence", () => {60+ const {definitions, document} = setup();61+ const Presence = definitions["chat-presence"];62+ const host = new Presence(); host.setAttribute("room", "room"); host.setAttribute("actor", key);63+ const output = new Node("output"); host.append(output); host.parent = document;64+ host.connectedCallback();65+ const now = Math.floor(Date.now() / 1000);66+ host.receive(event(20001, other, now, [["h", "room"]], "away"));67+ host.receive(event(20001, other, now - 1, [["h", "room"]], "online"));68+ assert.match(output.textContent, /away/);69+ host.receive(event(20002, other, now, [["h", "room"]], "typing"));70+ assert.match(output.textContent, /typing/);71+ assert.doesNotMatch(output.textContent, /away/);72+ host.disconnectedCallback();73+});74+75+test("presence publishing is opt in and always includes the room tag", async () => {76+ const {definitions, document, published} = setup();77+ const Presence = definitions["chat-presence"];78+ const host = new Presence(); host.setAttribute("room", "room"); host.setAttribute("actor", key);79+ const output = new Node("output");80+ host.append(output); host.parent = document; host.connectedCallback();81+ await host.publish(20001, "online");82+ assert.equal(published.length, 0);83+ await new Promise(resolve=>setTimeout(resolve,0));84+ host.applyPreference(true);85+ await host.publish(20001, "online");86+ assert.deepEqual(JSON.parse(JSON.stringify(published[0].tags)), [["h", "room"]]);87+ assert.equal(published[0].kind, 20001);88+ host.disconnectedCallback();89+});90+91+test("heartbeat repaint preserves resolved names and each user's status node", () => {92+ const {definitions, document} = setup();93+ const host = new definitions["chat-presence"](); host.setAttribute("room", "room"); host.setAttribute("actor", key);94+ const output = new Node("output"); host.append(output); host.parent = document; host.connectedCallback();95+ const now = Math.floor(Date.now()/1000), third = "c".repeat(64);96+ host.receive(event(20001, other, now, [["h","room"]]));97+ const row = output.childNodes[0], name = row.querySelector?.("nostr-name") || row;98+ name.textContent = "Alice";99+ for(let i=0;i<5;i++) host.paint();100+ assert.equal(output.childNodes[0], row, "unchanged heartbeat replaced the name element");101+ assert.match(output.textContent, /Alice/);102+ host.receive(event(20001, third, now, [["h","room"]], "working"));103+ host.receive(event(20002, third, now, [["h","room"]], "typing"));104+ assert.equal(output.childNodes[0],row,"another user's update replaced Alice");105+ assert.match(output.textContent,/Alice/);106+ host.receive(event(20001,other,now+1,[["h","room"]],"offline"));107+ host.receive(event(20001,other,now,[["h","room"]],"online"));108+ assert.doesNotMatch(output.textContent,/Alice/,"late heartbeat resurrected an offline user");109+ host.disconnectedCallback();110+});111+112+113+test("saved account preference enables chat without a chat control, and respects signer identity", async () => {114+ const {definitions,document,published,tiny} = setup({share:true});115+ const make = () => { const host=new definitions["chat-presence"]();host.setAttribute("room","room");host.setAttribute("actor",key);host.append(new Node("output"));host.parent=document;host.connectedCallback();return host; };116+ const host=make(); await new Promise(resolve=>setTimeout(resolve,0));117+ assert.equal(host.querySelector("input"),null);118+ assert.equal(published.length,1); assert.equal(published[0].kind,20001);119+ host.disconnectedCallback(); await new Promise(resolve=>setTimeout(resolve,0));120+ published.length=0; tiny.signer=()=>({signEvent:async()=>({}),getPublicKey:async()=>other});121+ const otherAccount=make(); await new Promise(resolve=>setTimeout(resolve,0));122+ assert.equal(published.length,0); otherAccount.disconnectedCallback();123+});124+125+test("account preference saves with a signed JSON request and restores checkbox after failure", async () => {126+ const options={}; const {definitions,document,saved,dispatched}=setup(options);127+ const host=new definitions["chat-preferences"]();host.setAttribute("actor",key);host.parent=document;128+ const input=new Node("input"), output=new Node("output");host.append(input,output);host.connectedCallback();129+ await new Promise(resolve=>setTimeout(resolve,0));130+ assert.equal(input.checked,false);assert.equal(input.disabled,false);131+ input.checked=true;await host.save();132+ assert.equal(saved.length,1);assert.equal(saved[0].path,"/chat/preferences");assert.equal(saved[0].method,"POST");133+ assert.deepEqual(JSON.parse(saved[0].body),{share_presence:true});assert.equal(saved[0].settings.contentType,"application/json");134+ assert.equal(dispatched[0].detail.actor,key);assert.equal(dispatched[0].detail.share_presence,true);135+ options.saveError="Signing refused";input.checked=false;await host.save();136+ assert.equal(input.checked,true);assert.match(output.textContent,/Signing refused/);137+ host.disconnectedCallback();138+});139+140+test("a preference response arriving after logout cannot enable presence", async () => {141+ let resolve;const {definitions,document,published}=setup({fetch:()=>new Promise(done=>{resolve=done;})});142+ const host=new definitions["chat-presence"]();host.setAttribute("room","room");host.setAttribute("actor",key);host.append(new Node("output"));host.parent=document;host.connectedCallback();143+ host.onIdentity();resolve({ok:true,json:async()=>({share_presence:true})});144+ await new Promise(done=>setTimeout(done,0));145+ assert.equal(host.enabled,false);assert.equal(published.length,0);host.disconnectedCallback();146+});
A scripts/grant-request.test.mjs +36 -0
@@ -0,0 +1,36 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import fs from 'node:fs';4+import vm from 'node:vm';5+const source=fs.readFileSync('internal/webui/grant-request.js','utf8');6+const id='a'.repeat(64),base='b'.repeat(64),agent='c'.repeat(64),operator='d'.repeat(64);7+const review=()=>({request_id:id,base,agent,operator,before:{name:"Agent",scope:{kinds:[9]}},after:{scope:{kinds:[9,30617]}},unsigned:{kind:30392,pubkey:operator,created_at:100,tags:[['grant-request',id],['grant-base',base],['e',id]],content:''}});8+function setup() {9+ const published=[],buttons=[{hasAttribute:n=>n==='data-grant-approve'},{hasAttribute:()=>false}]; let Ctor;10+ class Element {constructor(){this.isConnected=true;this.attrs={'data-review':JSON.stringify(review())};}getAttribute(n){return this.attrs[n]??null;}hasAttribute(n){return n in this.attrs;}querySelectorAll(){return buttons;}addEventListener(){} }11+ const signer={getPublicKey:async()=>operator},tiny={signer:()=>signer,signing:{publish:async e=>published.push(e)},navigate:async()=>{}};12+ vm.runInNewContext(source,{tiny,HTMLElement:Element,customElements:{define:(_,c)=>Ctor=c},AbortController,JSON,Date,Error,location:{href:'https://relay.test/approvals'}});13+ const host=new Ctor();host.report=m=>host.message=m;host.connectedCallback();14+ const fresh=review();fresh.unsigned.created_at=200;fresh.before={scope:{kinds:[9]},name:"Agent"};15+ const item={id,type:'grant',state:'open',asker:agent,asked:[operator],grant:fresh};host.latest=async()=>({item});16+ return {host,tiny,signer,buttons,published,item};17+}18+test('grant approval signs exactly one fresh replacement, not a reaction',async()=>{19+ const {host,published}=setup();await host.decide(null,true);20+ assert.equal(published.length,1);assert.equal(published[0].kind,30392);assert.equal(published[0].created_at,200);assert.deepEqual(Object.keys(published[0]),['kind','created_at','tags','content']);21+ assert.equal(published[0].tags.find(t=>t[0]==='grant-base')[1],base);22+});23+test('denial signs one reaction to the agent even when grant is paused',async()=>{24+ const {host,published,item}=setup();delete item.grant;item.grant_error='paused';await host.decide(null,false);25+ assert.equal(published.length,1);assert.equal(published[0].kind,7);assert.equal(published[0].content,'-');assert.equal(published[0].tags.find(t=>t[0]==='p')[1],agent);26+});27+for(const scenario of ['base','scope','closed','actor','identity','disconnect']) test('rejects changed '+scenario+' before signing',async()=>{28+ const {host,published,item,tiny}=setup();29+ host.latest=async()=>{if(scenario==='base')item.grant.base='e'.repeat(64);if(scenario==='scope')item.grant.after.scope.kinds.push(7);if(scenario==='closed')item.state='answered';if(scenario==='actor')item.asked=['f'.repeat(64)];if(scenario==='identity')tiny.signer=()=>({getPublicKey:async()=>operator});if(scenario==='disconnect'){host.isConnected=false;host.disconnectedCallback();}return {item};};30+ await host.decide(null,true);assert.equal(published.length,0);31+});32+test('double tap cannot publish an approval and denial together',async()=>{33+ const {host,published,buttons,item}=setup();let resolve;host.latest=()=>new Promise(done=>resolve=done);34+ const pending=host.decide(null,true);await new Promise(done=>setImmediate(done));assert.ok(buttons.every(b=>b.disabled));35+ await host.decide(null,false);resolve({item});await pending;assert.equal(published.length,1);assert.equal(published[0].kind,30392);36+});
A scripts/qa/buzz-chat-fixture.mjs +33 -0
@@ -0,0 +1,33 @@
1+#!/usr/bin/env node2+// Disposable local corpus for Buzz-style replies, task cards and decisions.3+import {finalizeEvent,getPublicKey} from 'nostr-tools/pure';4+import {createHash} from 'node:crypto';5+import {mkdir,writeFile} from 'node:fs/promises';6+const base=process.env.TINY_QA_URL||'http://127.0.0.1:18468';7+if(!['127.0.0.1','localhost'].includes(new URL(base).hostname)) throw Error('This fixture only runs locally.');8+export const keys=Object.fromEntries(['owner','alice','agent'].map((name,i)=>[name,Uint8Array.from(Buffer.from((i+1).toString(16).padStart(64,'0'),'hex'))]));9+const pub=Object.fromEntries(Object.entries(keys).map(([name,key])=>[name,getPublicKey(key)]));10+export async function signed(path,method,value,who='owner') {11+ const body=JSON.stringify(value),auth=finalizeEvent({kind:27235,created_at:Math.floor(Date.now()/1000),tags:[['u',base+path],['method',method],['payload',createHash('sha256').update(body).digest('hex')]],content:''},keys[who]);12+ const response=await fetch(base+path,{method,headers:{'content-type':'application/json',authorization:'Nostr '+Buffer.from(JSON.stringify(auth)).toString('base64')},body});13+ const result=await response.json();if(!response.ok||result.error||result.accepted===false&&!String(result.message).startsWith('duplicate:'))throw Error(JSON.stringify(result));return result;14+}15+export async function publish(who,kind,content,tags=[]) {const e=finalizeEvent({kind,content,tags,created_at:Math.floor(Date.now()/1000)},keys[who]);await signed('/events','POST',e,who);return e;}16+if(process.argv[1]?.endsWith('buzz-chat-fixture.mjs')) {17+ await mkdir('output/playwright/buzz',{recursive:true});18+ for(const name of ['owner','alice']){await signed('/manage/rpc','POST',{method:'setmember',params:[pub[name],{role:'member'}]});await publish(name,0,JSON.stringify({name:name==='owner'?'Morgan':'Alice'}));}19+ await publish('owner',30392,'',[['d',pub.agent],['p',pub.agent],['name','Build agent'],['room','work'],['k','0'],['k','9'],['k','11'],['k','12'],['k','7'],['k','1111'],['k','20001'],['k','20002'],['jobs','both'],['expiration',String(Math.floor(Date.now()/1000)+86400)]]);20+ await publish('agent',0,JSON.stringify({name:'Build agent'}));21+ await publish('owner',9007,'',[['h','work'],['name','Workshop'],['about','Agent tasks, decisions and chat'],['visibility','open']]);22+ await publish('owner',9000,'',[['h','work'],['p',pub.agent,'member']]);23+ const root=await publish('alice',9,'Could you prepare a preview?',[['h','work']]);24+ const reply=await publish('owner',9,'Keep the existing color palette.',[['h','work'],['e',root.id,'','reply',pub.alice]]);25+ await publish('alice',9,'And check the layout on mobile.',[['h','work'],['e',reply.id,'','reply',pub.owner]]);26+ const task=await publish('owner',5000,'Prepare the site preview',[['h','work'],['e',root.id],['p',pub.agent],['subject','Prepare the site preview']]);27+ await publish('agent',7000,'Desktop checks passed. Checking mobile navigation next.',[['h','work'],['e',task.id],['p',pub.owner],['status','processing','2 of 3 checks complete']]);28+ const approval=await publish('agent',9,'Publish the preview with the existing palette and updated mobile navigation?',[['h','work'],['e',root.id,'','root'],['request','approve'],['p',pub.owner],['subject','Publish preview']]);29+ const question=await publish('agent',9,'Which title should the preview use?',[['h','work'],['request','question'],['p',pub.owner],['subject','Preview title']]);30+ await publish('owner',9,'The task card holds progress updates. Replies stay in their thread.',[['h','work']]);31+ const fixture={base,pub,root:root.id,reply:reply.id,task:task.id,approval:approval.id,question:question.id};32+ await writeFile('output/playwright/buzz/fixture.json',JSON.stringify(fixture,null,2));console.log(JSON.stringify(fixture));33+}
A scripts/qa/buzz-runner-acp.mjs +57 -0
@@ -0,0 +1,57 @@
1+#!/usr/bin/env node2+// Small ACP peer used by buzz-runner-integration.mjs. It is intentionally3+// disposable: it never connects to a relay and only speaks JSON-RPC on stdio.4+let buffer = '';5+let permissionID = 40;6+let sessionID = 'qa-session';7+let awaitingPermission = false;8+if (process.env.FAKE_ACP_MODE === 'crash-once') {9+ const fs = await import('node:fs');10+ if (!fs.existsSync(process.env.FAKE_ACP_MARKER)) fs.writeFileSync(process.env.FAKE_ACP_MARKER, 'crashed');11+ else process.env.FAKE_ACP_MODE = '';12+}13+process.stdin.setEncoding('utf8');14+process.stdin.on('data', chunk => {15+ buffer += chunk;16+ while (buffer.includes('\n')) {17+ const newline = buffer.indexOf('\n');18+ const line = buffer.slice(0, newline);19+ buffer = buffer.slice(newline + 1);20+ if (!line.trim()) continue;21+ let frame;22+ try { frame = JSON.parse(line); } catch { continue; }23+ handle(frame);24+ }25+});26+function send(frame) { process.stdout.write(JSON.stringify(frame) + '\n'); }27+function handle(frame) {28+ if (frame.method === 'initialize') {29+ send({jsonrpc: '2.0', id: frame.id, result: {protocolVersion: 1}});30+ return;31+ }32+ if (frame.method === 'session/new') {33+ send({jsonrpc: '2.0', id: frame.id, result: {sessionId: sessionID}});34+ return;35+ }36+ if (frame.method === 'session/cancel') {37+ send({jsonrpc: '2.0', id: frame.id, result: {}});38+ return;39+ }40+ if (frame.method === 'session/prompt') {41+ if (process.env.FAKE_ACP_MODE === 'crash-once') process.exit(23);42+ send({jsonrpc: '2.0', method: 'session/update', params: {sessionId: sessionID, update: {sessionUpdate: 'tool_call', title: 'publish preview', status: 'running'}}});43+ awaitingPermission = true;44+ send({jsonrpc: '2.0', id: ++permissionID, method: 'session/request_permission', params: {sessionId: sessionID, toolCall: {title: 'Publish preview', rawInput: {path: 'public/preview.html'}}, options: [{optionId: 'allow', name: 'Allow once', kind: 'allow_once'}, {optionId: 'deny', name: 'Deny once', kind: 'reject_once'}]}});45+ return;46+ }47+ if (frame.id === permissionID && awaitingPermission) {48+ awaitingPermission = false;49+ const outcome = frame.result?.outcome;50+ if (outcome?.outcome !== 'selected' || outcome.optionId !== 'allow') {51+ send({jsonrpc: '2.0', id: 3, result: {stopReason: 'cancelled'}});52+ return;53+ }54+ send({jsonrpc: '2.0', method: 'session/update', params: {sessionId: sessionID, update: {sessionUpdate: 'agent_message_chunk', content: {type: 'text', text: 'Preview published.'}}}});55+ send({jsonrpc: '2.0', id: 3, result: {stopReason: 'end_turn'}});56+ }57+}
A scripts/qa/buzz-runner-integration.mjs +127 -0
@@ -0,0 +1,127 @@
1+#!/usr/bin/env node2+// End-to-end optional-agent check. All state, keys and processes are local.3+import {finalizeEvent, getPublicKey} from 'nostr-tools/pure';4+import {createHash, randomUUID} from 'node:crypto';5+import {mkdtemp, rm} from 'node:fs/promises';6+import {createServer} from 'node:net';7+import {tmpdir} from 'node:os';8+import {dirname, resolve} from 'node:path';9+import {spawn, spawnSync} from 'node:child_process';10+11+const root = resolve(dirname(new URL(import.meta.url).pathname), '../..');12+const binary = process.env.TINY_QA_BINARY || '/tmp/tiny-buzz-qa';13+const reservation = createServer();14+await new Promise((resolve,reject)=>{reservation.once('error',reject);reservation.listen(0,'127.0.0.1',resolve);});15+const port = reservation.address().port;16+await new Promise(resolve=>reservation.close(resolve));17+const base = `http://127.0.0.1:${port}`;18+const ws = `ws://127.0.0.1:${port}`;19+const keys = Object.fromEntries(['owner', 'agent'].map((name, i) => [name, Uint8Array.from(Buffer.from((i + 1).toString(16).padStart(64, '0'), 'hex'))]));20+const pub = Object.fromEntries(Object.entries(keys).map(([name, key]) => [name, getPublicKey(key)]));21+const data = await mkdtemp(`${tmpdir()}/tiny-buzz-runner-`);22+let relay, runner;23+try {24+ requireBinary();25+ run([binary, 'tenant', 'create', '--data-dir', data, '--name', 'main', '--owner', pub.owner]);26+ relay = spawn(binary, ['serve', '--data-dir', data, '--default-tenant', 'main', '--listen', `127.0.0.1:${port}`, '--allow-private-relays'], {stdio: ['ignore', 'ignore', 'pipe']});27+ await waitFor(`${base}/readyz`);28+ await publish('owner', 0, JSON.stringify({name: 'QA owner'}));29+ await publish('owner', 30392, '', [['d', pub.agent], ['p', pub.agent], ['name', 'QA agent'], ['room', 'work'], ['room', 'cancel'], ['room', 'crash'], ['k', '0'], ['k', '9'], ['k', '5000'], ['k', '6000'], ['k', '7000'], ['k', '7'], ['k', '1111'], ['k', '20001'], ['jobs', 'both'], ['expiration', String(Math.floor(Date.now() / 1000) + 3600)]]);30+ await publish('owner', 9007, '', [['h', 'work'], ['name', 'QA room'], ['visibility', 'open']]);31+ await publish('owner', 9000, '', [['h', 'work'], ['p', pub.agent, 'member']]);32+ for (const room of ['cancel', 'crash']) {33+ await publish('owner', 9007, '', [['h', room], ['name', `QA ${room} room`], ['visibility', 'open']]);34+ await publish('owner', 9000, '', [['h', room], ['p', pub.agent, 'member']]);35+ }36+ const acp = resolve(root, 'scripts/qa/buzz-runner-acp.mjs');37+ runner = startRunner(acp, `${data}/queue.json`);38+ const first = await publish('owner', 9, 'Please publish the preview.', [['h', 'work'], ['p', pub.agent]]);39+ const request = await waitForEvent({kinds: [5000], '#h': ['work'], '#p': [pub.agent]});40+ await waitForEvent({kinds:[7000], '#h':['work'], '#e':[request.id]}, event=>event.tags.some(tag=>tag[0]==='status'&&tag[1]==='processing'));41+ const approval = await waitForEvent({kinds: [9], '#h': ['work'], '#p': [pub.owner]}, event => event.tags.some(tag => tag[0] === 'request' && tag[1] === 'approve'));42+ assert(approval.tags.some(tag=>tag[0]==='expiration'&&Number(tag[1])>Math.floor(Date.now()/1000)), 'approval has no bounded expiry');43+ await publish('owner', 7, '+', [['e', approval.id], ['p', pub.agent], ['k', '9'], ['h', 'work']]);44+ const result = await waitForEvent({kinds: [6000], '#h': ['work'], '#e': [request.id]});45+ const reply = await waitForEvent({kinds: [9], '#h': ['work'], '#e': [first.id], '#p': [pub.owner]}, event => event.pubkey === pub.agent && event.content === 'Preview published.');46+ assert(result.content === 'Preview published.', `unexpected result: ${result.content}`);47+ assert(reply.content === 'Preview published.', `unexpected reply: ${reply.content}`);48+ await stopChild(runner);49+ runner = startRunner(acp, `${data}/cancel-queue.json`, {FAKE_ACP_MODE: 'wait'}, 'cancel');50+ const cancelMention = await publish('owner', 9, 'Start a task I will cancel.', [['h', 'cancel'], ['p', pub.agent]]);51+ const cancelRequest = await waitForEvent({kinds: [5000], '#h': ['cancel'], '#p': [pub.agent]}, event => event.content.includes('Start a task'));52+ const cancelApproval = await waitForEvent({kinds: [9], '#h': ['cancel'], '#p': [pub.owner]}, event => event.created_at >= cancelRequest.created_at && event.tags.some(tag => tag[0] === 'request' && tag[1] === 'approve'));53+ await publish('owner', 9, '/cancel', [['h', 'cancel'], ['p', pub.agent]]);54+ const canceled = await waitForEvent({kinds: [7000], '#h': ['cancel'], '#e': [cancelRequest.id]}, event => event.tags.some(tag => tag[0] === 'status' && tag[1] === 'error'));55+ assert(canceled.content === 'Canceled', `unexpected cancellation: ${canceled.content}`);56+ await stopChild(runner);57+ const marker = `${data}/crash-once.marker`;58+ runner = startRunner(acp, `${data}/crash-queue.json`, {FAKE_ACP_MODE: 'crash-once', FAKE_ACP_MARKER: marker}, 'crash');59+ const crashedMention = await publish('owner', 9, 'Crash once.', [['h', 'crash'], ['p', pub.agent]]);60+ const crashedRequest = await waitForEvent({kinds: [5000], '#h': ['crash'], '#p': [pub.agent]}, event => event.content.includes('Crash once'));61+ const crashed = await waitForEvent({kinds: [7000], '#h': ['crash'], '#e': [crashedRequest.id]}, event => event.tags.some(tag => tag[0] === 'status' && tag[1] === 'error'));62+ assert(crashed.content.includes('Agent stopped'), `missing crash report: ${crashed.content}`);63+ const retryMention = await publish('owner', 9, 'Recover after crash.', [['h', 'crash'], ['p', pub.agent]]);64+ const retryRequest = await waitForEvent({kinds: [5000], '#h': ['crash'], '#p': [pub.agent]}, event => event.content.includes('Recover after crash'));65+ const retryApproval = await waitForEvent({kinds: [9], '#h': ['crash'], '#p': [pub.owner]}, event => event.created_at >= retryRequest.created_at && event.tags.some(tag => tag[0] === 'request' && tag[1] === 'approve'));66+ await publish('owner', 7, '+', [['e', retryApproval.id], ['p', pub.agent], ['k', '9'], ['h', 'crash']]);67+ const retryResult = await waitForEvent({kinds: [6000], '#h': ['crash'], '#e': [retryRequest.id]});68+ assert(retryResult.content === 'Preview published.', `unexpected recovery result: ${retryResult.content}`);69+ console.log(JSON.stringify({ok: true, request: request.id, approval: approval.id, result: result.id, reply: reply.id, canceled: cancelMention.id, crash: crashedMention.id, recovered: retryMention.id}));70+} finally {71+ if (runner) runner.kill('SIGTERM');72+ if (relay) relay.kill('SIGTERM');73+ await Promise.all([exitAfter(runner), exitAfter(relay)]);74+ await rm(data, {recursive: true, force: true});75+}76+77+function startRunner(acp, state, extra = {}, room = 'work') {78+ return spawn(binary, ['agent', '--relay', ws, '--room', room, '--command', process.execPath, '--arg', acp, '--state', state, '--cwd', root], {env: {...process.env, TINY_AGENT_KEY: Buffer.from(keys.agent).toString('hex'), ...extra}, stdio: ['ignore', 'pipe', 'pipe']});79+}80+async function stopChild(child) {81+ child.kill('SIGTERM');82+ await exitAfter(child);83+}84+85+function requireBinary() {86+ const result = spawnSync(binary, ['version'], {encoding: 'utf8'});87+ if (result.status !== 0) throw new Error(`build ${binary} first`);88+}89+function run(args) {90+ const result = spawnSync(args[0], args.slice(1), {cwd: root, encoding: 'utf8'});91+ if (result.status !== 0) throw new Error(result.stderr || result.stdout || `command failed: ${args.join(' ')}`);92+}93+async function waitFor(url) {94+ for (let i = 0; i < 100; i++) {95+ try { const response = await fetch(url); if (response.ok) return; } catch {}96+ await new Promise(resolve => setTimeout(resolve, 50));97+ }98+ throw new Error(`timed out waiting for ${url}`);99+}100+async function signed(path, method, value, who) {101+ const body = JSON.stringify(value);102+ const auth = finalizeEvent({kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [['u', base + path], ['method', method], ['payload', createHash('sha256').update(body).digest('hex')], ['nonce', randomUUID()]], content: ''}, keys[who]);103+ const response = await fetch(base + path, {method, headers: {'content-type': 'application/json', authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString('base64')}`}, body});104+ const result = await response.json();105+ if (!response.ok || result.error || result.accepted === false) throw new Error(JSON.stringify(result));106+ return result;107+}108+async function publish(who, kind, content, tags = []) {109+ const event = finalizeEvent({kind, content, tags, created_at: Math.floor(Date.now() / 1000)}, keys[who]);110+ await signed('/events', 'POST', event, who);111+ return event;112+}113+async function query(filter) { return await signed('/query', 'POST', [filter], 'owner'); }114+async function waitForEvent(filter, predicate = () => true) {115+ for (let i = 0; i < 160; i++) {116+ const events = await query(filter);117+ const match = events.filter(predicate);118+ if (match.length) return match.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))[0];119+ await new Promise(resolve => setTimeout(resolve, 100));120+ }121+ throw new Error(`timed out waiting for event ${JSON.stringify(filter)}`);122+}123+function assert(condition, message) { if (!condition) throw new Error(message); }124+function exitAfter(child) {125+ if(!child || child.exitCode!==null || child.signalCode!==null) return Promise.resolve();126+ return new Promise(resolve=>{const timeout=setTimeout(()=>child.kill('SIGKILL'),3000); child.once('exit',()=>{clearTimeout(timeout);resolve();});});127+}
M scripts/rooms.test.mjs +2 -2