| 1 | diff --git a/cmd/tiny/agent.go b/cmd/tiny/agent.go |
| 2 | new file mode 100644 |
| 3 | index 0000000..53c0022 |
| 4 | --- /dev/null |
| 5 | +++ b/cmd/tiny/agent.go |
| 6 | @@ -0,0 +1,68 @@ |
| 7 | +package main |
| 8 | + |
| 9 | +import ( |
| 10 | + "context" |
| 11 | + "errors" |
| 12 | + "flag" |
| 13 | + "fmt" |
| 14 | + "io" |
| 15 | + "os" |
| 16 | + "path/filepath" |
| 17 | + "regexp" |
| 18 | + "strings" |
| 19 | + "time" |
| 20 | + |
| 21 | + "github.com/FelineStateMachine/tinyrelay/internal/agentrunner" |
| 22 | +) |
| 23 | + |
| 24 | +func agent(ctx context.Context, args []string, out io.Writer) error { |
| 25 | + fs := flag.NewFlagSet("tiny agent", flag.ContinueOnError) |
| 26 | + fs.SetOutput(out) |
| 27 | + relay := fs.String("relay", "", "Nostr relay WebSocket URL") |
| 28 | + command := fs.String("command", "", "agent executable") |
| 29 | + protocol := fs.String("protocol", "acp", "agent protocol: acp or command (text stdin/stdout)") |
| 30 | + keyEnv := fs.String("key-env", "TINY_AGENT_KEY", "environment variable containing the agent hex key or nsec") |
| 31 | + state := fs.String("state", "", "queue journal path (required)") |
| 32 | + cwd := fs.String("cwd", ".", "agent working directory") |
| 33 | + timeout := fs.Duration("timeout", 30*time.Minute, "maximum task and approval wait") |
| 34 | + var rooms, commandArgs []string |
| 35 | + fs.Func("room", "authorized room id (repeatable)", func(value string) error { |
| 36 | + if !regexp.MustCompile(`^[a-z0-9_-]{1,64}$`).MatchString(value) { |
| 37 | + return errors.New("invalid room id") |
| 38 | + } |
| 39 | + rooms = append(rooms, value) |
| 40 | + return nil |
| 41 | + }) |
| 42 | + fs.Func("arg", "argument passed directly to the agent (repeatable)", func(value string) error { commandArgs = append(commandArgs, value); return nil }) |
| 43 | + if err := fs.Parse(args); err != nil { |
| 44 | + return err |
| 45 | + } |
| 46 | + if *relay == "" || *command == "" || *state == "" || len(rooms) == 0 { |
| 47 | + return errors.New("agent requires --relay, --room, --command and --state") |
| 48 | + } |
| 49 | + if fs.NArg() != 0 || (*protocol != "acp" && *protocol != "command") || *timeout <= 0 { |
| 50 | + return errors.New("invalid agent options") |
| 51 | + } |
| 52 | + secret, err := secretKey(os.Getenv(*keyEnv)) |
| 53 | + if err != nil { |
| 54 | + return fmt.Errorf("agent key: %w", err) |
| 55 | + } |
| 56 | + directory, err := filepath.Abs(*cwd) |
| 57 | + if err != nil { |
| 58 | + return err |
| 59 | + } |
| 60 | + info, err := os.Stat(directory) |
| 61 | + if err != nil || !info.IsDir() { |
| 62 | + return errors.New("agent working directory does not exist") |
| 63 | + } |
| 64 | + // The subprocess does not need the relay signing key. It receives messages |
| 65 | + // through stdio and returns requests and output to this signing boundary. |
| 66 | + environment := []string{} |
| 67 | + for _, entry := range os.Environ() { |
| 68 | + if !strings.HasPrefix(entry, *keyEnv+"=") { |
| 69 | + environment = append(environment, entry) |
| 70 | + } |
| 71 | + } |
| 72 | + log := func(err error) { fmt.Fprintln(out, err) } |
| 73 | + 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}}) |
| 74 | +} |
| 75 | diff --git a/cmd/tiny/main.go b/cmd/tiny/main.go |
| 76 | index 1098a29..0aa9945 100644 |
| 77 | --- a/cmd/tiny/main.go |
| 78 | +++ b/cmd/tiny/main.go |
| 79 | @@ -37,7 +37,7 @@ func main() { |
| 80 | // to out. Returning errors to main keeps process exit at the command boundary. |
| 81 | func run(ctx context.Context, args []string, out io.Writer) error { |
| 82 | if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { |
| 83 | - _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version") |
| 84 | + _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny agent --relay URL --room ROOM --command PROGRAM [--protocol acp|command]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version") |
| 85 | return err |
| 86 | } |
| 87 | switch args[0] { |
| 88 | @@ -48,6 +48,8 @@ func run(ctx context.Context, args []string, out io.Writer) error { |
| 89 | return json.NewEncoder(out).Encode(templates.Names()) |
| 90 | case "serve": |
| 91 | return serve(ctx, args[1:], out) |
| 92 | + case "agent": |
| 93 | + return agent(ctx, args[1:], out) |
| 94 | case "tenant": |
| 95 | return tenantCommand(ctx, args[1:], out) |
| 96 | case "git-token": |
| 97 | diff --git a/docs/agents.md b/docs/agents.md |
| 98 | index 58914db..0cc08e2 100644 |
| 99 | --- a/docs/agents.md |
| 100 | +++ b/docs/agents.md |
| 101 | @@ -67,6 +67,12 @@ A `maintain` grant makes the agent a maintainer of that repository, the same as |
| 102 | |
| 103 | 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. |
| 104 | |
| 105 | +### Requesting additional access |
| 106 | + |
| 107 | +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. |
| 108 | + |
| 109 | +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. |
| 110 | + |
| 111 | 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. |
| 112 | |
| 113 | ## Pause and revoke |
| 114 | @@ -211,6 +217,33 @@ Over MCP, `request_job` builds a request, `job_feedback` and `job_result` build |
| 115 | |
| 116 | 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. |
| 117 | |
| 118 | +## Agent chat features |
| 119 | + |
| 120 | +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`. |
| 121 | + |
| 122 | +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. |
| 123 | + |
| 124 | +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. |
| 125 | + |
| 126 | +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. |
| 127 | + |
| 128 | +## Run a local agent |
| 129 | + |
| 130 | +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. |
| 131 | + |
| 132 | +```sh |
| 133 | +tiny agent \ |
| 134 | + --relay wss://relay.example/ \ |
| 135 | + --room general \ |
| 136 | + --command /usr/local/bin/my-agent \ |
| 137 | + --state /var/lib/tiny-agent/queue.json \ |
| 138 | + --protocol acp |
| 139 | +``` |
| 140 | + |
| 141 | +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`. |
| 142 | + |
| 143 | +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. |
| 144 | + |
| 145 | ## Static sites |
| 146 | |
| 147 | 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. |
| 148 | diff --git a/docs/fixi.md b/docs/fixi.md |
| 149 | index 5296908..bf81172 100644 |
| 150 | --- a/docs/fixi.md |
| 151 | +++ b/docs/fixi.md |
| 152 | @@ -10,7 +10,7 @@ b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855 |
| 153 | |
| 154 | `/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. |
| 155 | |
| 156 | -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. |
| 157 | +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. |
| 158 | |
| 159 | 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. |
| 160 | |
| 161 | diff --git a/docs/mcp.md b/docs/mcp.md |
| 162 | index be8ca6c..9399333 100644 |
| 163 | --- a/docs/mcp.md |
| 164 | +++ b/docs/mcp.md |
| 165 | @@ -116,6 +116,7 @@ Write tools, which publish through the same path as `POST /events`: |
| 166 | | `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`. | |
| 167 | | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. | |
| 168 | | `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. | |
| 169 | +| `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. | |
| 170 | | `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. | |
| 171 | | `job_feedback` | Report progress on a long task with a kind 7000 event naming the request, the requester and a status. | |
| 172 | | `job_result` | Deliver a long task's output with an event of the request kind plus 1000, naming the request and the requester. | |
| 173 | @@ -134,6 +135,10 @@ An agent whose grant holds `propose` on a repository publishes issues, pull requ |
| 174 | |
| 175 | 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. |
| 176 | |
| 177 | +### Requesting grant access |
| 178 | + |
| 179 | +`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. |
| 180 | + |
| 181 | ### Static sites |
| 182 | |
| 183 | `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). |
| 184 | diff --git a/docs/rooms.md b/docs/rooms.md |
| 185 | index 1eaf24d..19416ef 100644 |
| 186 | --- a/docs/rooms.md |
| 187 | +++ b/docs/rooms.md |
| 188 | @@ -65,10 +65,14 @@ One-to-one messages require a connected signer with NIP-44 support. The message |
| 189 | |
| 190 | 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. |
| 191 | |
| 192 | +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. |
| 193 | + |
| 194 | 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. |
| 195 | |
| 196 | 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. |
| 197 | |
| 198 | +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. |
| 199 | + |
| 200 | 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. |
| 201 | |
| 202 | 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). |
| 203 | diff --git a/internal/agentrunner/acp_test.go b/internal/agentrunner/acp_test.go |
| 204 | new file mode 100644 |
| 205 | index 0000000..0e49643 |
| 206 | --- /dev/null |
| 207 | +++ b/internal/agentrunner/acp_test.go |
| 208 | @@ -0,0 +1,89 @@ |
| 209 | +package agentrunner |
| 210 | + |
| 211 | +import ( |
| 212 | + "bufio" |
| 213 | + "context" |
| 214 | + "encoding/json" |
| 215 | + "errors" |
| 216 | + "os" |
| 217 | + "path/filepath" |
| 218 | + "testing" |
| 219 | + "time" |
| 220 | +) |
| 221 | + |
| 222 | +func TestACPHandshakeAndStream(t *testing.T) { |
| 223 | + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 224 | + defer cancel() |
| 225 | + 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") |
| 226 | + if err != nil || result != "hello from agent" { |
| 227 | + t.Fatalf("result=%q err=%v", result, err) |
| 228 | + } |
| 229 | +} |
| 230 | + |
| 231 | +func TestACPCancellationNotifiesAgentBeforeExit(t *testing.T) { |
| 232 | + marker := filepath.Join(t.TempDir(), "cancelled") |
| 233 | + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) |
| 234 | + defer cancel() |
| 235 | + _, 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") |
| 236 | + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { |
| 237 | + t.Fatalf("err=%v", err) |
| 238 | + } |
| 239 | + if _, err := os.Stat(marker); err != nil { |
| 240 | + t.Fatalf("agent did not receive cancellation: %v", err) |
| 241 | + } |
| 242 | +} |
| 243 | + |
| 244 | +func TestCommandCleansUpDescendantProcess(t *testing.T) { |
| 245 | + marker := filepath.Join(t.TempDir(), "child-survived") |
| 246 | + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) |
| 247 | + defer cancel() |
| 248 | + _, err := RunProcess(ctx, ProcessOptions{Command: "sh", Args: []string{"-c", "(sleep 0.5; touch \"$1\") & wait", "descendant", marker}, CWD: t.TempDir(), Protocol: "command"}, "hello") |
| 249 | + if err == nil { |
| 250 | + t.Fatal("expected cancellation") |
| 251 | + } |
| 252 | + time.Sleep(200 * time.Millisecond) |
| 253 | + if _, err := os.Stat(marker); err == nil { |
| 254 | + t.Fatal("descendant survived") |
| 255 | + } |
| 256 | +} |
| 257 | +func TestACPHelper(t *testing.T) { |
| 258 | + if os.Getenv("TINY_ACP_HELPER") != "1" && os.Getenv("TINY_ACP_CANCEL_HELPER") != "1" { |
| 259 | + return |
| 260 | + } |
| 261 | + scan := bufio.NewScanner(os.Stdin) |
| 262 | + encoder := json.NewEncoder(os.Stdout) |
| 263 | + for scan.Scan() { |
| 264 | + var message map[string]any |
| 265 | + if json.Unmarshal(scan.Bytes(), &message) != nil { |
| 266 | + os.Exit(2) |
| 267 | + } |
| 268 | + params, _ := message["params"].(map[string]any) |
| 269 | + switch message["method"] { |
| 270 | + case "initialize": |
| 271 | + if params["protocolVersion"] != float64(1) { |
| 272 | + os.Exit(3) |
| 273 | + } |
| 274 | + encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"protocolVersion": 1}}) |
| 275 | + case "session/new": |
| 276 | + if params["cwd"] == nil || params["mcpServers"] == nil { |
| 277 | + os.Exit(4) |
| 278 | + } |
| 279 | + encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"sessionId": "test-session"}}) |
| 280 | + case "session/prompt": |
| 281 | + if params["sessionId"] != "test-session" || params["prompt"] == nil { |
| 282 | + os.Exit(5) |
| 283 | + } |
| 284 | + if os.Getenv("TINY_ACP_CANCEL_HELPER") == "1" { |
| 285 | + continue |
| 286 | + } |
| 287 | + 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"}}}}) |
| 288 | + encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"stopReason": "end_turn"}}) |
| 289 | + case "session/cancel": |
| 290 | + if marker := os.Getenv("TINY_ACP_CANCEL_MARKER"); marker != "" { |
| 291 | + _ = os.WriteFile(marker, []byte("cancelled"), 0600) |
| 292 | + } |
| 293 | + os.Exit(0) |
| 294 | + } |
| 295 | + } |
| 296 | + os.Exit(0) |
| 297 | +} |
| 298 | diff --git a/internal/agentrunner/process.go b/internal/agentrunner/process.go |
| 299 | new file mode 100644 |
| 300 | index 0000000..51ea2ca |
| 301 | --- /dev/null |
| 302 | +++ b/internal/agentrunner/process.go |
| 303 | @@ -0,0 +1,325 @@ |
| 304 | +package agentrunner |
| 305 | + |
| 306 | +import ( |
| 307 | + "bufio" |
| 308 | + "bytes" |
| 309 | + "context" |
| 310 | + "encoding/json" |
| 311 | + "errors" |
| 312 | + "fmt" |
| 313 | + "io" |
| 314 | + "os/exec" |
| 315 | + "strings" |
| 316 | + "sync" |
| 317 | + "time" |
| 318 | +) |
| 319 | + |
| 320 | +type PermissionOption struct { |
| 321 | + ID string `json:"optionId"` |
| 322 | + Name string `json:"name"` |
| 323 | + Kind string `json:"kind"` |
| 324 | +} |
| 325 | +type Permission struct { |
| 326 | + Title string |
| 327 | + Details string |
| 328 | + Options []PermissionOption |
| 329 | +} |
| 330 | +type ProcessOptions struct { |
| 331 | + Command string |
| 332 | + Args []string |
| 333 | + Env []string |
| 334 | + CWD string |
| 335 | + Protocol string |
| 336 | + Progress func(string) |
| 337 | + Permission func(context.Context, Permission) (string, error) |
| 338 | +} |
| 339 | +type rpcFrame struct { |
| 340 | + ID json.RawMessage `json:"id,omitempty"` |
| 341 | + Method string `json:"method,omitempty"` |
| 342 | + Params json.RawMessage `json:"params,omitempty"` |
| 343 | + Result json.RawMessage `json:"result,omitempty"` |
| 344 | + Error *rpcError `json:"error,omitempty"` |
| 345 | +} |
| 346 | +type rpcError struct { |
| 347 | + Code int `json:"code"` |
| 348 | + Message string `json:"message"` |
| 349 | +} |
| 350 | +type rpcProcess struct { |
| 351 | + input io.WriteCloser |
| 352 | + frames chan rpcFrame |
| 353 | + finished chan struct{} |
| 354 | + readErr error |
| 355 | + writeMu sync.Mutex |
| 356 | + cancelOnce sync.Once |
| 357 | + sessionMu sync.RWMutex |
| 358 | + opts ProcessOptions |
| 359 | + session string |
| 360 | + text strings.Builder |
| 361 | +} |
| 362 | + |
| 363 | +func RunProcess(ctx context.Context, opts ProcessOptions, prompt string) (string, error) { |
| 364 | + if opts.Command == "" { |
| 365 | + return "", errors.New("agent command is required") |
| 366 | + } |
| 367 | + if opts.Protocol == "command" { |
| 368 | + return runCommand(ctx, opts, prompt) |
| 369 | + } |
| 370 | + if opts.Protocol != "acp" { |
| 371 | + return "", errors.New("agent protocol must be acp or command") |
| 372 | + } |
| 373 | + command := exec.CommandContext(ctx, opts.Command, opts.Args...) |
| 374 | + configureProcessGroup(command) |
| 375 | + var process *rpcProcess |
| 376 | + command.Cancel = func() error { |
| 377 | + if process != nil && process.hasSession() { |
| 378 | + process.cancelSession() |
| 379 | + } |
| 380 | + terminateProcess(command) |
| 381 | + return nil |
| 382 | + } |
| 383 | + command.Dir = opts.CWD |
| 384 | + command.Env = opts.Env |
| 385 | + command.WaitDelay = 2 * time.Second |
| 386 | + input, err := command.StdinPipe() |
| 387 | + if err != nil { |
| 388 | + return "", err |
| 389 | + } |
| 390 | + output, err := command.StdoutPipe() |
| 391 | + if err != nil { |
| 392 | + return "", err |
| 393 | + } |
| 394 | + // Agent stderr belongs to the local runner, never to a room transcript. |
| 395 | + command.Stderr = io.Discard |
| 396 | + p := &rpcProcess{input: input, frames: make(chan rpcFrame, 1), finished: make(chan struct{}), opts: opts} |
| 397 | + process = p |
| 398 | + if err := command.Start(); err != nil { |
| 399 | + return "", fmt.Errorf("start agent: %w", err) |
| 400 | + } |
| 401 | + readCtx, stopRead := context.WithCancel(ctx) |
| 402 | + go p.read(readCtx, output) |
| 403 | + defer func() { stopRead(); input.Close(); terminateProcess(command); command.Wait(); <-p.finished }() |
| 404 | + init, err := p.call(ctx, 1, "initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{}, "clientInfo": map[string]string{"name": "tiny", "version": "1"}}) |
| 405 | + if err != nil { |
| 406 | + return "", err |
| 407 | + } |
| 408 | + var initialized struct { |
| 409 | + Version int `json:"protocolVersion"` |
| 410 | + } |
| 411 | + if json.Unmarshal(init, &initialized) != nil || initialized.Version != 1 { |
| 412 | + return "", errors.New("agent does not support ACP version 1") |
| 413 | + } |
| 414 | + raw, err := p.call(ctx, 2, "session/new", map[string]any{"cwd": opts.CWD, "mcpServers": []any{}}) |
| 415 | + if err != nil { |
| 416 | + return "", err |
| 417 | + } |
| 418 | + var session struct { |
| 419 | + ID string `json:"sessionId"` |
| 420 | + } |
| 421 | + if json.Unmarshal(raw, &session) != nil || session.ID == "" { |
| 422 | + return "", errors.New("agent returned no session id") |
| 423 | + } |
| 424 | + p.setSession(session.ID) |
| 425 | + raw, err = p.call(ctx, 3, "session/prompt", map[string]any{"sessionId": session.ID, "prompt": []any{map[string]string{"type": "text", "text": prompt}}}) |
| 426 | + if err != nil { |
| 427 | + if ctx.Err() != nil { |
| 428 | + p.cancelSession() |
| 429 | + } |
| 430 | + return "", err |
| 431 | + } |
| 432 | + var done struct { |
| 433 | + Reason string `json:"stopReason"` |
| 434 | + } |
| 435 | + if json.Unmarshal(raw, &done) != nil { |
| 436 | + return "", errors.New("invalid ACP prompt result") |
| 437 | + } |
| 438 | + if done.Reason == "cancelled" { |
| 439 | + return "", context.Canceled |
| 440 | + } |
| 441 | + return strings.TrimSpace(p.text.String()), nil |
| 442 | +} |
| 443 | +func (p *rpcProcess) read(ctx context.Context, out io.Reader) { |
| 444 | + defer close(p.finished) |
| 445 | + defer close(p.frames) |
| 446 | + scanner := bufio.NewScanner(out) |
| 447 | + scanner.Buffer(make([]byte, 4096), 1<<20) |
| 448 | + for scanner.Scan() { |
| 449 | + var frame rpcFrame |
| 450 | + if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil { |
| 451 | + p.readErr = fmt.Errorf("invalid ACP response: %w", err) |
| 452 | + return |
| 453 | + } |
| 454 | + select { |
| 455 | + case p.frames <- frame: |
| 456 | + case <-ctx.Done(): |
| 457 | + return |
| 458 | + } |
| 459 | + } |
| 460 | + p.readErr = scanner.Err() |
| 461 | +} |
| 462 | +func (p *rpcProcess) write(value any) error { |
| 463 | + p.writeMu.Lock() |
| 464 | + defer p.writeMu.Unlock() |
| 465 | + return json.NewEncoder(p.input).Encode(value) |
| 466 | +} |
| 467 | +func (p *rpcProcess) call(ctx context.Context, id int, method string, params any) (json.RawMessage, error) { |
| 468 | + if err := p.write(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil { |
| 469 | + return nil, err |
| 470 | + } |
| 471 | + for { |
| 472 | + select { |
| 473 | + case <-ctx.Done(): |
| 474 | + if method == "session/prompt" && p.session != "" { |
| 475 | + p.cancelSession() |
| 476 | + } |
| 477 | + return nil, ctx.Err() |
| 478 | + case frame, ok := <-p.frames: |
| 479 | + if !ok { |
| 480 | + if p.readErr != nil { |
| 481 | + return nil, p.readErr |
| 482 | + } |
| 483 | + return nil, errors.New("agent process exited before replying") |
| 484 | + } |
| 485 | + if frame.Method != "" { |
| 486 | + if err := p.receive(ctx, frame); err != nil { |
| 487 | + return nil, err |
| 488 | + } |
| 489 | + continue |
| 490 | + } |
| 491 | + if string(frame.ID) != fmt.Sprint(id) { |
| 492 | + continue |
| 493 | + } |
| 494 | + if frame.Error != nil { |
| 495 | + return nil, fmt.Errorf("ACP %s: %s", method, frame.Error.Message) |
| 496 | + } |
| 497 | + return frame.Result, nil |
| 498 | + } |
| 499 | + } |
| 500 | +} |
| 501 | + |
| 502 | +func (p *rpcProcess) cancelSession() { |
| 503 | + p.cancelOnce.Do(func() { |
| 504 | + session := p.getSession() |
| 505 | + if session == "" { |
| 506 | + return |
| 507 | + } |
| 508 | + done := make(chan struct{}) |
| 509 | + go func() { |
| 510 | + defer close(done) |
| 511 | + _ = p.write(map[string]any{"jsonrpc": "2.0", "method": "session/cancel", "params": map[string]string{"sessionId": session}}) |
| 512 | + }() |
| 513 | + timer := time.NewTimer(100 * time.Millisecond) |
| 514 | + defer timer.Stop() |
| 515 | + select { |
| 516 | + case <-done: |
| 517 | + <-timer.C |
| 518 | + case <-timer.C: |
| 519 | + // Closing the pipe releases a blocked write before we join its goroutine. |
| 520 | + _ = p.input.Close() |
| 521 | + <-done |
| 522 | + } |
| 523 | + }) |
| 524 | +} |
| 525 | + |
| 526 | +func (p *rpcProcess) setSession(session string) { |
| 527 | + p.sessionMu.Lock() |
| 528 | + p.session = session |
| 529 | + p.sessionMu.Unlock() |
| 530 | +} |
| 531 | +func (p *rpcProcess) getSession() string { |
| 532 | + p.sessionMu.RLock() |
| 533 | + defer p.sessionMu.RUnlock() |
| 534 | + return p.session |
| 535 | +} |
| 536 | +func (p *rpcProcess) hasSession() bool { return p.getSession() != "" } |
| 537 | +func (p *rpcProcess) receive(ctx context.Context, frame rpcFrame) error { |
| 538 | + var params struct { |
| 539 | + Session string `json:"sessionId"` |
| 540 | + Update struct { |
| 541 | + Type string `json:"sessionUpdate"` |
| 542 | + Title string `json:"title"` |
| 543 | + Status string `json:"status"` |
| 544 | + Content struct { |
| 545 | + Type string `json:"type"` |
| 546 | + Text string `json:"text"` |
| 547 | + } `json:"content"` |
| 548 | + } `json:"update"` |
| 549 | + Tool struct { |
| 550 | + Title string `json:"title"` |
| 551 | + Input json.RawMessage `json:"rawInput"` |
| 552 | + } `json:"toolCall"` |
| 553 | + Options []PermissionOption `json:"options"` |
| 554 | + } |
| 555 | + if json.Unmarshal(frame.Params, ¶ms) != nil { |
| 556 | + return errors.New("invalid ACP parameters") |
| 557 | + } |
| 558 | + if params.Session != "" && params.Session != p.session { |
| 559 | + return errors.New("ACP update belongs to another session") |
| 560 | + } |
| 561 | + if frame.Method == "session/update" { |
| 562 | + if params.Update.Type == "agent_message_chunk" && params.Update.Content.Type == "text" { |
| 563 | + if p.text.Len()+len(params.Update.Content.Text) > 64000 { |
| 564 | + return errors.New("agent reply exceeds 64 KiB") |
| 565 | + } |
| 566 | + p.text.WriteString(params.Update.Content.Text) |
| 567 | + } |
| 568 | + if (params.Update.Type == "tool_call" || params.Update.Type == "tool_call_update") && p.opts.Progress != nil { |
| 569 | + p.opts.Progress(strings.TrimSpace(params.Update.Title + " " + params.Update.Status)) |
| 570 | + } |
| 571 | + return nil |
| 572 | + } |
| 573 | + if len(frame.ID) == 0 { |
| 574 | + return nil |
| 575 | + } |
| 576 | + if frame.Method != "session/request_permission" { |
| 577 | + return p.write(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "error": rpcError{Code: -32601, Message: "Client capability unavailable"}}) |
| 578 | + } |
| 579 | + outcome := map[string]string{"outcome": "cancelled"} |
| 580 | + if p.opts.Permission != nil { |
| 581 | + if len(params.Tool.Input) > 16000 { |
| 582 | + return errors.New("permission request is too large to review") |
| 583 | + } |
| 584 | + choice, err := p.opts.Permission(ctx, Permission{Title: params.Tool.Title, Details: string(params.Tool.Input), Options: params.Options}) |
| 585 | + if err != nil { |
| 586 | + return err |
| 587 | + } |
| 588 | + for _, option := range params.Options { |
| 589 | + if option.ID == choice { |
| 590 | + outcome = map[string]string{"outcome": "selected", "optionId": choice} |
| 591 | + break |
| 592 | + } |
| 593 | + } |
| 594 | + } |
| 595 | + return p.write(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "result": map[string]any{"outcome": outcome}}) |
| 596 | +} |
| 597 | + |
| 598 | +type boundedOutput struct { |
| 599 | + bytes.Buffer |
| 600 | + limit int |
| 601 | +} |
| 602 | + |
| 603 | +func (b *boundedOutput) Write(p []byte) (int, error) { |
| 604 | + if b.Len()+len(p) > b.limit { |
| 605 | + return 0, errors.New("agent output exceeds 64 KiB") |
| 606 | + } |
| 607 | + return b.Buffer.Write(p) |
| 608 | +} |
| 609 | +func runCommand(ctx context.Context, opts ProcessOptions, prompt string) (string, error) { |
| 610 | + command := exec.CommandContext(ctx, opts.Command, opts.Args...) |
| 611 | + configureProcessGroup(command) |
| 612 | + command.Cancel = func() error { |
| 613 | + terminateProcess(command) |
| 614 | + return nil |
| 615 | + } |
| 616 | + command.Dir = opts.CWD |
| 617 | + command.Env = opts.Env |
| 618 | + command.WaitDelay = 2 * time.Second |
| 619 | + command.Stdin = strings.NewReader(prompt) |
| 620 | + output := &boundedOutput{limit: 64000} |
| 621 | + command.Stdout = output |
| 622 | + command.Stderr = io.Discard |
| 623 | + defer terminateProcess(command) |
| 624 | + if err := command.Run(); err != nil { |
| 625 | + return "", fmt.Errorf("agent command failed: %w", err) |
| 626 | + } |
| 627 | + return strings.TrimSpace(output.String()), nil |
| 628 | +} |
| 629 | diff --git a/internal/agentrunner/process_unix.go b/internal/agentrunner/process_unix.go |
| 630 | new file mode 100644 |
| 631 | index 0000000..868d48c |
| 632 | --- /dev/null |
| 633 | +++ b/internal/agentrunner/process_unix.go |
| 634 | @@ -0,0 +1,21 @@ |
| 635 | +//go:build !windows |
| 636 | + |
| 637 | +package agentrunner |
| 638 | + |
| 639 | +import ( |
| 640 | + "os/exec" |
| 641 | + "syscall" |
| 642 | +) |
| 643 | + |
| 644 | +func configureProcessGroup(command *exec.Cmd) { |
| 645 | + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} |
| 646 | +} |
| 647 | + |
| 648 | +func terminateProcess(command *exec.Cmd) { |
| 649 | + if command.Process == nil { |
| 650 | + return |
| 651 | + } |
| 652 | + _ = syscall.Kill(-command.Process.Pid, syscall.SIGTERM) |
| 653 | + _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL) |
| 654 | + _ = command.Process.Kill() |
| 655 | +} |
| 656 | diff --git a/internal/agentrunner/process_windows.go b/internal/agentrunner/process_windows.go |
| 657 | new file mode 100644 |
| 658 | index 0000000..ed5ec53 |
| 659 | --- /dev/null |
| 660 | +++ b/internal/agentrunner/process_windows.go |
| 661 | @@ -0,0 +1,13 @@ |
| 662 | +//go:build windows |
| 663 | + |
| 664 | +package agentrunner |
| 665 | + |
| 666 | +import "os/exec" |
| 667 | + |
| 668 | +func configureProcessGroup(command *exec.Cmd) {} |
| 669 | + |
| 670 | +func terminateProcess(command *exec.Cmd) { |
| 671 | + if command.Process != nil { |
| 672 | + _ = command.Process.Kill() |
| 673 | + } |
| 674 | +} |
| 675 | diff --git a/internal/agentrunner/relay.go b/internal/agentrunner/relay.go |
| 676 | new file mode 100644 |
| 677 | index 0000000..c36d7c1 |
| 678 | --- /dev/null |
| 679 | +++ b/internal/agentrunner/relay.go |
| 680 | @@ -0,0 +1,226 @@ |
| 681 | +package agentrunner |
| 682 | + |
| 683 | +import ( |
| 684 | + "context" |
| 685 | + "encoding/json" |
| 686 | + "errors" |
| 687 | + "fmt" |
| 688 | + "net/url" |
| 689 | + "slices" |
| 690 | + "strings" |
| 691 | + "sync" |
| 692 | + "time" |
| 693 | + |
| 694 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 695 | + "github.com/coder/websocket" |
| 696 | +) |
| 697 | + |
| 698 | +type RelayClient struct { |
| 699 | + URL, Secret, PubKey string |
| 700 | + Rooms []string |
| 701 | + OnEvent func(event.Event) |
| 702 | + OnReady func() |
| 703 | + OnError func(error) |
| 704 | + mu sync.Mutex |
| 705 | + conn *websocket.Conn |
| 706 | + connection context.Context |
| 707 | + pending map[string]chan error |
| 708 | +} |
| 709 | + |
| 710 | +func (c *RelayClient) Run(ctx context.Context) error { |
| 711 | + parsed, err := url.Parse(c.URL) |
| 712 | + if err != nil || parsed.Host == "" || (parsed.Scheme != "ws" && parsed.Scheme != "wss") || parsed.User != nil || parsed.Fragment != "" { |
| 713 | + return errors.New("agent relay must be a ws or wss URL") |
| 714 | + } |
| 715 | + c.PubKey, err = event.PublicKey(c.Secret) |
| 716 | + if err != nil { |
| 717 | + return err |
| 718 | + } |
| 719 | + delay := time.Second |
| 720 | + for ctx.Err() == nil { |
| 721 | + err = c.connect(ctx) |
| 722 | + if ctx.Err() != nil { |
| 723 | + break |
| 724 | + } |
| 725 | + if c.OnError != nil { |
| 726 | + c.OnError(err) |
| 727 | + } |
| 728 | + timer := time.NewTimer(delay) |
| 729 | + select { |
| 730 | + case <-ctx.Done(): |
| 731 | + timer.Stop() |
| 732 | + return ctx.Err() |
| 733 | + case <-timer.C: |
| 734 | + } |
| 735 | + delay = min(delay*2, 30*time.Second) |
| 736 | + } |
| 737 | + return ctx.Err() |
| 738 | +} |
| 739 | +func (c *RelayClient) connect(ctx context.Context) error { |
| 740 | + conn, _, err := websocket.Dial(ctx, c.URL, nil) |
| 741 | + if err != nil { |
| 742 | + return err |
| 743 | + } |
| 744 | + conn.SetReadLimit(1 << 20) |
| 745 | + scope, cancel := context.WithCancel(ctx) |
| 746 | + defer cancel() |
| 747 | + defer conn.CloseNow() |
| 748 | + c.mu.Lock() |
| 749 | + c.conn = conn |
| 750 | + c.connection = scope |
| 751 | + c.pending = map[string]chan error{} |
| 752 | + c.mu.Unlock() |
| 753 | + defer func() { |
| 754 | + c.mu.Lock() |
| 755 | + if c.conn == conn { |
| 756 | + c.conn = nil |
| 757 | + } |
| 758 | + c.mu.Unlock() |
| 759 | + }() |
| 760 | + if err := c.subscribe(scope, conn); err != nil { |
| 761 | + return err |
| 762 | + } |
| 763 | + authID := "" |
| 764 | + for { |
| 765 | + _, raw, err := conn.Read(scope) |
| 766 | + if err != nil { |
| 767 | + return err |
| 768 | + } |
| 769 | + var frame []json.RawMessage |
| 770 | + if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 { |
| 771 | + continue |
| 772 | + } |
| 773 | + var kind string |
| 774 | + json.Unmarshal(frame[0], &kind) |
| 775 | + switch kind { |
| 776 | + case "AUTH": |
| 777 | + var challenge string |
| 778 | + if json.Unmarshal(frame[1], &challenge) != nil { |
| 779 | + continue |
| 780 | + } |
| 781 | + auth := event.Event{Kind: 22242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"relay", c.URL}, {"challenge", challenge}}} |
| 782 | + if err := event.Sign(&auth, c.Secret); err != nil { |
| 783 | + return err |
| 784 | + } |
| 785 | + authID = auth.ID |
| 786 | + if err := writeRelay(scope, conn, []any{"AUTH", auth}); err != nil { |
| 787 | + return err |
| 788 | + } |
| 789 | + case "OK": |
| 790 | + if len(frame) < 3 { |
| 791 | + continue |
| 792 | + } |
| 793 | + var id string |
| 794 | + var ok bool |
| 795 | + var reason string |
| 796 | + json.Unmarshal(frame[1], &id) |
| 797 | + json.Unmarshal(frame[2], &ok) |
| 798 | + if len(frame) > 3 { |
| 799 | + json.Unmarshal(frame[3], &reason) |
| 800 | + } |
| 801 | + if id == authID { |
| 802 | + if !ok { |
| 803 | + return fmt.Errorf("agent authentication refused: %s", reason) |
| 804 | + } |
| 805 | + if err := c.subscribe(scope, conn); err != nil { |
| 806 | + return err |
| 807 | + } |
| 808 | + if c.OnReady != nil { |
| 809 | + c.OnReady() |
| 810 | + } |
| 811 | + continue |
| 812 | + } |
| 813 | + var result error |
| 814 | + if !ok && !strings.HasPrefix(reason, "duplicate:") { |
| 815 | + result = fmt.Errorf("relay refused event: %s", reason) |
| 816 | + } |
| 817 | + c.mu.Lock() |
| 818 | + wait := c.pending[id] |
| 819 | + c.mu.Unlock() |
| 820 | + if wait != nil { |
| 821 | + select { |
| 822 | + case wait <- result: |
| 823 | + default: |
| 824 | + } |
| 825 | + } |
| 826 | + case "EOSE": |
| 827 | + if c.OnReady != nil { |
| 828 | + c.OnReady() |
| 829 | + } |
| 830 | + case "EVENT": |
| 831 | + if len(frame) != 3 { |
| 832 | + continue |
| 833 | + } |
| 834 | + e, err := event.Parse(frame[2]) |
| 835 | + if err != nil { |
| 836 | + continue |
| 837 | + } |
| 838 | + if c.OnEvent != nil { |
| 839 | + c.OnEvent(e) |
| 840 | + } |
| 841 | + case "CLOSED": |
| 842 | + if len(frame) > 2 { |
| 843 | + var reason string |
| 844 | + json.Unmarshal(frame[2], &reason) |
| 845 | + if !strings.HasPrefix(reason, "auth-required:") { |
| 846 | + return fmt.Errorf("agent subscription closed: %s", reason) |
| 847 | + } |
| 848 | + } |
| 849 | + } |
| 850 | + } |
| 851 | +} |
| 852 | +func (c *RelayClient) subscribe(ctx context.Context, conn *websocket.Conn) error { |
| 853 | + since := time.Now().Add(-5 * time.Minute).Unix() |
| 854 | + 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}}) |
| 855 | +} |
| 856 | +func writeRelay(ctx context.Context, conn *websocket.Conn, value any) error { |
| 857 | + raw, err := json.Marshal(value) |
| 858 | + if err != nil { |
| 859 | + return err |
| 860 | + } |
| 861 | + return conn.Write(ctx, websocket.MessageText, raw) |
| 862 | +} |
| 863 | +func (c *RelayClient) Publish(ctx context.Context, e event.Event) (event.Event, error) { |
| 864 | + if e.CreatedAt == 0 { |
| 865 | + e.CreatedAt = time.Now().Unix() |
| 866 | + } |
| 867 | + if err := event.Sign(&e, c.Secret); err != nil { |
| 868 | + return e, err |
| 869 | + } |
| 870 | + c.mu.Lock() |
| 871 | + conn, scope := c.conn, c.connection |
| 872 | + if conn == nil { |
| 873 | + c.mu.Unlock() |
| 874 | + return e, errors.New("agent relay is disconnected") |
| 875 | + } |
| 876 | + wait := make(chan error, 1) |
| 877 | + c.pending[e.ID] = wait |
| 878 | + c.mu.Unlock() |
| 879 | + defer func() { c.mu.Lock(); delete(c.pending, e.ID); c.mu.Unlock() }() |
| 880 | + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| 881 | + defer cancel() |
| 882 | + if err := writeRelay(ctx, conn, []any{"EVENT", e}); err != nil { |
| 883 | + return e, err |
| 884 | + } |
| 885 | + select { |
| 886 | + case err := <-wait: |
| 887 | + return e, err |
| 888 | + case <-ctx.Done(): |
| 889 | + return e, ctx.Err() |
| 890 | + case <-scope.Done(): |
| 891 | + return e, errors.New("agent relay disconnected before acknowledgement") |
| 892 | + } |
| 893 | +} |
| 894 | +func (c *RelayClient) Mention(e event.Event) (Mention, bool) { |
| 895 | + 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")) { |
| 896 | + return Mention{}, false |
| 897 | + } |
| 898 | + if e.CreatedAt > time.Now().Add(time.Minute).Unix() || e.CreatedAt < time.Now().Add(-24*time.Hour).Unix() || len(e.Content) > 64000 { |
| 899 | + return Mention{}, false |
| 900 | + } |
| 901 | + root := event.RoomReplyRoot(e) |
| 902 | + if root == "" { |
| 903 | + root = e.ID |
| 904 | + } |
| 905 | + return Mention{Room: event.Tag(e, "h"), EventID: e.ID, Author: e.PubKey, Prompt: e.Content, Root: root, Kind: e.Kind, CreatedAt: e.CreatedAt}, true |
| 906 | +} |
| 907 | diff --git a/internal/agentrunner/runner.go b/internal/agentrunner/runner.go |
| 908 | new file mode 100644 |
| 909 | index 0000000..f413b44 |
| 910 | --- /dev/null |
| 911 | +++ b/internal/agentrunner/runner.go |
| 912 | @@ -0,0 +1,375 @@ |
| 913 | +// Package agentrunner runs an optional agent process outside the relay daemon. |
| 914 | +package agentrunner |
| 915 | + |
| 916 | +import ( |
| 917 | + "context" |
| 918 | + "encoding/json" |
| 919 | + "errors" |
| 920 | + "fmt" |
| 921 | + "os" |
| 922 | + "path/filepath" |
| 923 | + "sync" |
| 924 | + "time" |
| 925 | +) |
| 926 | + |
| 927 | +var ( |
| 928 | + ErrDuplicate = errors.New("agent runner: duplicate event") |
| 929 | + ErrUnauthorizedRoom = errors.New("agent runner: unauthorized room") |
| 930 | + ErrQueueFull = errors.New("agent runner: room queue full") |
| 931 | + ErrStopped = errors.New("agent runner: stopped") |
| 932 | +) |
| 933 | + |
| 934 | +type Mention struct { |
| 935 | + Room string `json:"room"` |
| 936 | + EventID string `json:"event_id"` |
| 937 | + Author string `json:"author"` |
| 938 | + Prompt string `json:"prompt"` |
| 939 | + Root string `json:"root"` |
| 940 | + Kind int `json:"kind"` |
| 941 | + CreatedAt int64 `json:"created_at"` |
| 942 | +} |
| 943 | +type Handler func(context.Context, []Mention) error |
| 944 | +type Options struct { |
| 945 | + AllowedRooms []string |
| 946 | + QueueLimit int |
| 947 | + StatePath string |
| 948 | + Handle Handler |
| 949 | + OnError func(error) |
| 950 | +} |
| 951 | +type roomQueue struct { |
| 952 | + pending, active []Mention |
| 953 | + cancel context.CancelFunc |
| 954 | + wake chan struct{} |
| 955 | +} |
| 956 | +type journal struct { |
| 957 | + Pending map[string][]Mention `json:"pending"` |
| 958 | + Seen []string `json:"seen"` |
| 959 | +} |
| 960 | +type Runner struct { |
| 961 | + mu sync.Mutex |
| 962 | + rooms map[string]*roomQueue |
| 963 | + seen map[string]bool |
| 964 | + order []string |
| 965 | + opts Options |
| 966 | + ctx context.Context |
| 967 | + cancel context.CancelFunc |
| 968 | + wg sync.WaitGroup |
| 969 | + stopped bool |
| 970 | +} |
| 971 | + |
| 972 | +func New(opts Options) (*Runner, error) { |
| 973 | + if opts.Handle == nil || len(opts.AllowedRooms) == 0 || len(opts.AllowedRooms) > 32 { |
| 974 | + return nil, errors.New("agent runner: a handler and 1 to 32 rooms are required") |
| 975 | + } |
| 976 | + if opts.QueueLimit <= 0 { |
| 977 | + opts.QueueLimit = 32 |
| 978 | + } |
| 979 | + if opts.QueueLimit > 256 { |
| 980 | + return nil, errors.New("agent runner: queue limit cannot exceed 256") |
| 981 | + } |
| 982 | + r := &Runner{opts: opts, rooms: map[string]*roomQueue{}, seen: map[string]bool{}} |
| 983 | + for _, room := range opts.AllowedRooms { |
| 984 | + if room == "" { |
| 985 | + return nil, ErrUnauthorizedRoom |
| 986 | + } |
| 987 | + r.rooms[room] = &roomQueue{wake: make(chan struct{}, 1)} |
| 988 | + } |
| 989 | + if err := r.restore(); err != nil { |
| 990 | + return nil, err |
| 991 | + } |
| 992 | + return r, nil |
| 993 | +} |
| 994 | +func (r *Runner) Start(ctx context.Context) { |
| 995 | + r.mu.Lock() |
| 996 | + defer r.mu.Unlock() |
| 997 | + if r.ctx != nil || r.stopped { |
| 998 | + return |
| 999 | + } |
| 1000 | + r.ctx, r.cancel = context.WithCancel(ctx) |
| 1001 | + for room, q := range r.rooms { |
| 1002 | + r.wg.Add(1) |
| 1003 | + go r.work(room, q) |
| 1004 | + } |
| 1005 | +} |
| 1006 | +func (r *Runner) Enqueue(m Mention) error { |
| 1007 | + if m.EventID == "" || m.Author == "" || len(m.Prompt) > 64*1024 { |
| 1008 | + return errors.New("agent runner: invalid mention") |
| 1009 | + } |
| 1010 | + r.mu.Lock() |
| 1011 | + defer r.mu.Unlock() |
| 1012 | + if r.stopped { |
| 1013 | + return ErrStopped |
| 1014 | + } |
| 1015 | + q, ok := r.rooms[m.Room] |
| 1016 | + if !ok { |
| 1017 | + return ErrUnauthorizedRoom |
| 1018 | + } |
| 1019 | + if r.seen[m.EventID] { |
| 1020 | + return ErrDuplicate |
| 1021 | + } |
| 1022 | + if len(q.pending) >= r.opts.QueueLimit { |
| 1023 | + return ErrQueueFull |
| 1024 | + } |
| 1025 | + q.pending = append(q.pending, m) |
| 1026 | + r.seen[m.EventID] = true |
| 1027 | + r.order = append(r.order, m.EventID) |
| 1028 | + if err := r.save(); err != nil { |
| 1029 | + q.pending = q.pending[:len(q.pending)-1] |
| 1030 | + delete(r.seen, m.EventID) |
| 1031 | + r.order = r.order[:len(r.order)-1] |
| 1032 | + return err |
| 1033 | + } |
| 1034 | + select { |
| 1035 | + case q.wake <- struct{}{}: |
| 1036 | + default: |
| 1037 | + } |
| 1038 | + return nil |
| 1039 | +} |
| 1040 | +func (r *Runner) CancelFrom(room, author string) bool { |
| 1041 | + r.mu.Lock() |
| 1042 | + defer r.mu.Unlock() |
| 1043 | + q := r.rooms[room] |
| 1044 | + if q == nil { |
| 1045 | + return false |
| 1046 | + } |
| 1047 | + allowed := false |
| 1048 | + for _, m := range append(append([]Mention{}, q.active...), q.pending...) { |
| 1049 | + if author == "" || m.Author == author { |
| 1050 | + allowed = true |
| 1051 | + break |
| 1052 | + } |
| 1053 | + } |
| 1054 | + if !allowed { |
| 1055 | + return false |
| 1056 | + } |
| 1057 | + filtered := q.pending[:0] |
| 1058 | + for _, m := range q.pending { |
| 1059 | + if author == "" || m.Author == author { |
| 1060 | + continue |
| 1061 | + } |
| 1062 | + filtered = append(filtered, m) |
| 1063 | + } |
| 1064 | + q.pending = filtered |
| 1065 | + if q.cancel != nil && anyAuthor(q.active, author) { |
| 1066 | + q.cancel() |
| 1067 | + } |
| 1068 | + if err := r.save(); err != nil { |
| 1069 | + r.report(err) |
| 1070 | + } |
| 1071 | + return true |
| 1072 | +} |
| 1073 | +func (r *Runner) Cancel(room string) error { r.CancelFrom(room, ""); return nil } |
| 1074 | +func (r *Runner) Stop() { |
| 1075 | + r.mu.Lock() |
| 1076 | + r.stopped = true |
| 1077 | + if r.cancel != nil { |
| 1078 | + r.cancel() |
| 1079 | + } |
| 1080 | + r.mu.Unlock() |
| 1081 | + r.wg.Wait() |
| 1082 | +} |
| 1083 | +func (r *Runner) work(room string, q *roomQueue) { |
| 1084 | + defer r.wg.Done() |
| 1085 | + for { |
| 1086 | + select { |
| 1087 | + case <-r.ctx.Done(): |
| 1088 | + return |
| 1089 | + default: |
| 1090 | + } |
| 1091 | + r.mu.Lock() |
| 1092 | + if len(q.pending) == 0 { |
| 1093 | + r.mu.Unlock() |
| 1094 | + select { |
| 1095 | + case <-r.ctx.Done(): |
| 1096 | + return |
| 1097 | + case <-q.wake: |
| 1098 | + } |
| 1099 | + continue |
| 1100 | + } |
| 1101 | + batch := takeBatch(q.pending) |
| 1102 | + q.pending = q.pending[len(batch):] |
| 1103 | + q.active = batch |
| 1104 | + ctx, cancel := context.WithCancel(r.ctx) |
| 1105 | + q.cancel = cancel |
| 1106 | + if err := r.save(); err != nil { |
| 1107 | + r.report(err) |
| 1108 | + cancel() |
| 1109 | + q.pending = append(batch, q.pending...) |
| 1110 | + q.active = nil |
| 1111 | + q.cancel = nil |
| 1112 | + r.mu.Unlock() |
| 1113 | + return |
| 1114 | + } |
| 1115 | + r.mu.Unlock() |
| 1116 | + err := r.opts.Handle(ctx, batch) |
| 1117 | + cancel() |
| 1118 | + r.mu.Lock() |
| 1119 | + if r.ctx.Err() != nil { |
| 1120 | + q.pending = append(batch, q.pending...) |
| 1121 | + } |
| 1122 | + q.active = nil |
| 1123 | + q.cancel = nil |
| 1124 | + if saveErr := r.save(); saveErr != nil { |
| 1125 | + r.report(saveErr) |
| 1126 | + } |
| 1127 | + r.mu.Unlock() |
| 1128 | + if err != nil && !errors.Is(err, context.Canceled) { |
| 1129 | + r.report(fmt.Errorf("agent room %s: %w", room, err)) |
| 1130 | + } |
| 1131 | + } |
| 1132 | +} |
| 1133 | + |
| 1134 | +func takeBatch(pending []Mention) []Mention { |
| 1135 | + if len(pending) == 0 { |
| 1136 | + return nil |
| 1137 | + } |
| 1138 | + root := pending[0].Root |
| 1139 | + n := 1 |
| 1140 | + for n < len(pending) && pending[n].Root == root { |
| 1141 | + n++ |
| 1142 | + } |
| 1143 | + return append([]Mention(nil), pending[:n]...) |
| 1144 | +} |
| 1145 | + |
| 1146 | +func anyAuthor(events []Mention, author string) bool { |
| 1147 | + if author == "" { |
| 1148 | + return len(events) > 0 |
| 1149 | + } |
| 1150 | + for _, event := range events { |
| 1151 | + if event.Author == author { |
| 1152 | + return true |
| 1153 | + } |
| 1154 | + } |
| 1155 | + return false |
| 1156 | +} |
| 1157 | +func (r *Runner) report(err error) { |
| 1158 | + if r.opts.OnError != nil { |
| 1159 | + r.opts.OnError(err) |
| 1160 | + } |
| 1161 | +} |
| 1162 | +func (r *Runner) restore() error { |
| 1163 | + if r.opts.StatePath == "" { |
| 1164 | + return nil |
| 1165 | + } |
| 1166 | + raw, err := os.ReadFile(r.opts.StatePath) |
| 1167 | + if errors.Is(err, os.ErrNotExist) { |
| 1168 | + return nil |
| 1169 | + } |
| 1170 | + if err != nil { |
| 1171 | + return fmt.Errorf("read agent state: %w", err) |
| 1172 | + } |
| 1173 | + if len(raw) > 32<<20 { |
| 1174 | + return errors.New("agent state exceeds 32 MiB") |
| 1175 | + } |
| 1176 | + var state journal |
| 1177 | + if err := json.Unmarshal(raw, &state); err != nil { |
| 1178 | + return fmt.Errorf("decode agent state: %w", err) |
| 1179 | + } |
| 1180 | + for _, id := range state.Seen { |
| 1181 | + if !r.seen[id] { |
| 1182 | + r.seen[id] = true |
| 1183 | + r.order = append(r.order, id) |
| 1184 | + } |
| 1185 | + } |
| 1186 | + for room, queue := range state.Pending { |
| 1187 | + q := r.rooms[room] |
| 1188 | + if q == nil { |
| 1189 | + return errors.New("agent state contains an unconfigured room") |
| 1190 | + } |
| 1191 | + if len(queue) > r.opts.QueueLimit*2 { |
| 1192 | + return ErrQueueFull |
| 1193 | + } |
| 1194 | + q.pending = queue |
| 1195 | + } |
| 1196 | + return nil |
| 1197 | +} |
| 1198 | +func (r *Runner) save() error { |
| 1199 | + if r.opts.StatePath == "" { |
| 1200 | + return nil |
| 1201 | + } |
| 1202 | + // Keep a bounded replay history while retaining every queued event. |
| 1203 | + active := map[string]bool{} |
| 1204 | + pending := map[string][]Mention{} |
| 1205 | + for room, q := range r.rooms { |
| 1206 | + pending[room] = append(append([]Mention{}, q.active...), q.pending...) |
| 1207 | + for _, m := range pending[room] { |
| 1208 | + active[m.EventID] = true |
| 1209 | + } |
| 1210 | + } |
| 1211 | + if len(r.order) > 10000 { |
| 1212 | + keep := make(map[string]bool, len(active)+10000) |
| 1213 | + for id := range active { |
| 1214 | + keep[id] = true |
| 1215 | + } |
| 1216 | + remaining := 10000 - len(active) |
| 1217 | + if remaining < 0 { |
| 1218 | + remaining = 0 |
| 1219 | + } |
| 1220 | + for i := len(r.order) - 1; i >= 0 && remaining > 0; i-- { |
| 1221 | + if !keep[r.order[i]] { |
| 1222 | + keep[r.order[i]] = true |
| 1223 | + remaining-- |
| 1224 | + } |
| 1225 | + } |
| 1226 | + kept := make([]string, 0, len(keep)) |
| 1227 | + for _, id := range r.order { |
| 1228 | + if keep[id] { |
| 1229 | + kept = append(kept, id) |
| 1230 | + } |
| 1231 | + } |
| 1232 | + for id := range r.seen { |
| 1233 | + if !keep[id] { |
| 1234 | + delete(r.seen, id) |
| 1235 | + } |
| 1236 | + } |
| 1237 | + r.order = kept |
| 1238 | + } |
| 1239 | + raw, err := json.Marshal(journal{Pending: pending, Seen: r.order}) |
| 1240 | + if err != nil { |
| 1241 | + return err |
| 1242 | + } |
| 1243 | + dir := filepath.Dir(r.opts.StatePath) |
| 1244 | + if err := os.MkdirAll(dir, 0700); err != nil { |
| 1245 | + return err |
| 1246 | + } |
| 1247 | + file, err := os.CreateTemp(dir, ".tiny-agent-*") |
| 1248 | + if err != nil { |
| 1249 | + return err |
| 1250 | + } |
| 1251 | + name := file.Name() |
| 1252 | + defer os.Remove(name) |
| 1253 | + if _, err = file.Write(raw); err != nil { |
| 1254 | + file.Close() |
| 1255 | + return err |
| 1256 | + } |
| 1257 | + if err = file.Sync(); err != nil { |
| 1258 | + file.Close() |
| 1259 | + return err |
| 1260 | + } |
| 1261 | + if err = file.Close(); err != nil { |
| 1262 | + return err |
| 1263 | + } |
| 1264 | + return os.Rename(name, r.opts.StatePath) |
| 1265 | +} |
| 1266 | + |
| 1267 | +// Drain waits until queued work has finished, or the caller cancels. |
| 1268 | +func (r *Runner) Drain(ctx context.Context) error { |
| 1269 | + ticker := time.NewTicker(10 * time.Millisecond) |
| 1270 | + defer ticker.Stop() |
| 1271 | + for { |
| 1272 | + r.mu.Lock() |
| 1273 | + empty := true |
| 1274 | + for _, q := range r.rooms { |
| 1275 | + empty = empty && len(q.pending) == 0 && len(q.active) == 0 |
| 1276 | + } |
| 1277 | + r.mu.Unlock() |
| 1278 | + if empty { |
| 1279 | + return nil |
| 1280 | + } |
| 1281 | + select { |
| 1282 | + case <-ctx.Done(): |
| 1283 | + return ctx.Err() |
| 1284 | + case <-ticker.C: |
| 1285 | + } |
| 1286 | + } |
| 1287 | +} |
| 1288 | diff --git a/internal/agentrunner/runner_test.go b/internal/agentrunner/runner_test.go |
| 1289 | new file mode 100644 |
| 1290 | index 0000000..a0187c1 |
| 1291 | --- /dev/null |
| 1292 | +++ b/internal/agentrunner/runner_test.go |
| 1293 | @@ -0,0 +1,196 @@ |
| 1294 | +package agentrunner |
| 1295 | + |
| 1296 | +import ( |
| 1297 | + "context" |
| 1298 | + "errors" |
| 1299 | + "path/filepath" |
| 1300 | + "sync" |
| 1301 | + "testing" |
| 1302 | + "time" |
| 1303 | +) |
| 1304 | + |
| 1305 | +func TestRunnerSerializesBatchesAndOtherRoomsProgress(t *testing.T) { |
| 1306 | + start := make(chan string, 3) |
| 1307 | + release := make(chan struct{}) |
| 1308 | + var mu sync.Mutex |
| 1309 | + var batches [][]Mention |
| 1310 | + r, err := New(Options{AllowedRooms: []string{"one", "two"}, Handle: func(ctx context.Context, b []Mention) error { |
| 1311 | + mu.Lock() |
| 1312 | + batches = append(batches, b) |
| 1313 | + mu.Unlock() |
| 1314 | + start <- b[0].EventID |
| 1315 | + if b[0].EventID == "a" { |
| 1316 | + select { |
| 1317 | + case <-ctx.Done(): |
| 1318 | + return ctx.Err() |
| 1319 | + case <-release: |
| 1320 | + } |
| 1321 | + } |
| 1322 | + return nil |
| 1323 | + }}) |
| 1324 | + if err != nil { |
| 1325 | + t.Fatal(err) |
| 1326 | + } |
| 1327 | + r.Start(context.Background()) |
| 1328 | + defer r.Stop() |
| 1329 | + add := func(id, room string) { |
| 1330 | + t.Helper() |
| 1331 | + if err := r.Enqueue(Mention{EventID: id, Author: "alice", Room: room}); err != nil { |
| 1332 | + t.Fatal(err) |
| 1333 | + } |
| 1334 | + } |
| 1335 | + add("a", "one") |
| 1336 | + <-start |
| 1337 | + add("b", "one") |
| 1338 | + add("c", "one") |
| 1339 | + add("d", "two") |
| 1340 | + select { |
| 1341 | + case id := <-start: |
| 1342 | + if id != "d" { |
| 1343 | + t.Fatal(id) |
| 1344 | + } |
| 1345 | + case <-time.After(time.Second): |
| 1346 | + t.Fatal("second room blocked") |
| 1347 | + } |
| 1348 | + if err := r.Enqueue(Mention{EventID: "b", Author: "alice", Room: "one"}); !errors.Is(err, ErrDuplicate) { |
| 1349 | + t.Fatal(err) |
| 1350 | + } |
| 1351 | + close(release) |
| 1352 | + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| 1353 | + defer cancel() |
| 1354 | + if err := r.Drain(ctx); err != nil { |
| 1355 | + t.Fatal(err) |
| 1356 | + } |
| 1357 | + mu.Lock() |
| 1358 | + defer mu.Unlock() |
| 1359 | + if len(batches) != 3 || len(batches[2]) != 2 { |
| 1360 | + t.Fatalf("batches=%v", batches) |
| 1361 | + } |
| 1362 | +} |
| 1363 | +func TestRunnerStopsActiveTaskAndRestoresJournal(t *testing.T) { |
| 1364 | + path := filepath.Join(t.TempDir(), "queue.json") |
| 1365 | + started := make(chan struct{}) |
| 1366 | + r, err := New(Options{AllowedRooms: []string{"one"}, StatePath: path, Handle: func(ctx context.Context, b []Mention) error { close(started); <-ctx.Done(); return ctx.Err() }}) |
| 1367 | + if err != nil { |
| 1368 | + t.Fatal(err) |
| 1369 | + } |
| 1370 | + r.Start(context.Background()) |
| 1371 | + r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "a"}) |
| 1372 | + <-started |
| 1373 | + if r.CancelFrom("one", "other") { |
| 1374 | + t.Fatal("another author canceled work") |
| 1375 | + } |
| 1376 | + done := make(chan struct{}) |
| 1377 | + go func() { r.Stop(); close(done) }() |
| 1378 | + select { |
| 1379 | + case <-done: |
| 1380 | + case <-time.After(time.Second): |
| 1381 | + t.Fatal("stop hung") |
| 1382 | + } |
| 1383 | + if err := r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "b"}); !errors.Is(err, ErrStopped) { |
| 1384 | + t.Fatal(err) |
| 1385 | + } |
| 1386 | + got := make(chan string, 1) |
| 1387 | + restored, err := New(Options{AllowedRooms: []string{"one"}, StatePath: path, Handle: func(ctx context.Context, b []Mention) error { got <- b[0].EventID; return nil }}) |
| 1388 | + if err != nil { |
| 1389 | + t.Fatal(err) |
| 1390 | + } |
| 1391 | + restored.Start(context.Background()) |
| 1392 | + defer restored.Stop() |
| 1393 | + select { |
| 1394 | + case id := <-got: |
| 1395 | + if id != "a" { |
| 1396 | + t.Fatal(id) |
| 1397 | + } |
| 1398 | + case <-time.After(time.Second): |
| 1399 | + t.Fatal("unfinished work not recovered") |
| 1400 | + } |
| 1401 | +} |
| 1402 | +func TestRunnerRejectsUnconfiguredRoomsAndRecoversAfterFailure(t *testing.T) { |
| 1403 | + got := make(chan string, 2) |
| 1404 | + r, err := New(Options{AllowedRooms: []string{"one"}, QueueLimit: 1, Handle: func(ctx context.Context, b []Mention) error { |
| 1405 | + got <- b[0].EventID |
| 1406 | + return errors.New("process crashed") |
| 1407 | + }}) |
| 1408 | + if err != nil { |
| 1409 | + t.Fatal(err) |
| 1410 | + } |
| 1411 | + if err := r.Enqueue(Mention{Room: "two", Author: "a", EventID: "a"}); !errors.Is(err, ErrUnauthorizedRoom) { |
| 1412 | + t.Fatal(err) |
| 1413 | + } |
| 1414 | + r.Enqueue(Mention{Room: "one", Author: "a", EventID: "a"}) |
| 1415 | + if err := r.Enqueue(Mention{Room: "one", Author: "a", EventID: "b"}); !errors.Is(err, ErrQueueFull) { |
| 1416 | + t.Fatal(err) |
| 1417 | + } |
| 1418 | + r.Start(context.Background()) |
| 1419 | + defer r.Stop() |
| 1420 | + <-got |
| 1421 | + r.Enqueue(Mention{Room: "one", Author: "a", EventID: "b"}) |
| 1422 | + select { |
| 1423 | + case <-got: |
| 1424 | + case <-time.After(time.Second): |
| 1425 | + t.Fatal("queue died after process crash") |
| 1426 | + } |
| 1427 | +} |
| 1428 | + |
| 1429 | +func TestRunnerDoesNotMixThreadBatches(t *testing.T) { |
| 1430 | + got := make(chan []Mention, 2) |
| 1431 | + r, err := New(Options{AllowedRooms: []string{"one"}, Handle: func(ctx context.Context, batch []Mention) error { |
| 1432 | + got <- batch |
| 1433 | + return nil |
| 1434 | + }}) |
| 1435 | + if err != nil { |
| 1436 | + t.Fatal(err) |
| 1437 | + } |
| 1438 | + r.Start(context.Background()) |
| 1439 | + defer r.Stop() |
| 1440 | + 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"}} { |
| 1441 | + if err := r.Enqueue(m); err != nil { |
| 1442 | + t.Fatal(err) |
| 1443 | + } |
| 1444 | + } |
| 1445 | + first := <-got |
| 1446 | + second := <-got |
| 1447 | + if len(first) != 2 || first[0].Root != "root-a" || len(second) != 1 || second[0].Root != "root-b" { |
| 1448 | + t.Fatalf("batches=%v,%v", first, second) |
| 1449 | + } |
| 1450 | +} |
| 1451 | + |
| 1452 | +func TestRunnerCancelFromKeepsOtherAuthorsQueued(t *testing.T) { |
| 1453 | + started := make(chan struct{}) |
| 1454 | + release := make(chan struct{}) |
| 1455 | + var startOnce sync.Once |
| 1456 | + r, err := New(Options{AllowedRooms: []string{"one"}, Handle: func(ctx context.Context, batch []Mention) error { |
| 1457 | + startOnce.Do(func() { close(started) }) |
| 1458 | + select { |
| 1459 | + case <-release: |
| 1460 | + return nil |
| 1461 | + case <-ctx.Done(): |
| 1462 | + return ctx.Err() |
| 1463 | + } |
| 1464 | + }}) |
| 1465 | + if err != nil { |
| 1466 | + t.Fatal(err) |
| 1467 | + } |
| 1468 | + r.Start(context.Background()) |
| 1469 | + defer r.Stop() |
| 1470 | + if err := r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "a", Root: "root"}); err != nil { |
| 1471 | + t.Fatal(err) |
| 1472 | + } |
| 1473 | + <-started |
| 1474 | + if err := r.Enqueue(Mention{Room: "one", Author: "bob", EventID: "b", Root: "root-b"}); err != nil { |
| 1475 | + t.Fatal(err) |
| 1476 | + } |
| 1477 | + if !r.CancelFrom("one", "alice") { |
| 1478 | + t.Fatal("alice cancellation was not accepted") |
| 1479 | + } |
| 1480 | + close(release) |
| 1481 | + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| 1482 | + defer cancel() |
| 1483 | + if err := r.Drain(ctx); err != nil { |
| 1484 | + t.Fatal(err) |
| 1485 | + } |
| 1486 | + if err := r.Enqueue(Mention{Room: "one", Author: "bob", EventID: "b"}); !errors.Is(err, ErrDuplicate) { |
| 1487 | + t.Fatalf("bob event should remain seen: %v", err) |
| 1488 | + } |
| 1489 | +} |
| 1490 | diff --git a/internal/agentrunner/service.go b/internal/agentrunner/service.go |
| 1491 | new file mode 100644 |
| 1492 | index 0000000..5252fa0 |
| 1493 | --- /dev/null |
| 1494 | +++ b/internal/agentrunner/service.go |
| 1495 | @@ -0,0 +1,234 @@ |
| 1496 | +package agentrunner |
| 1497 | + |
| 1498 | +import ( |
| 1499 | + "context" |
| 1500 | + "encoding/json" |
| 1501 | + "errors" |
| 1502 | + "fmt" |
| 1503 | + "slices" |
| 1504 | + "strconv" |
| 1505 | + "strings" |
| 1506 | + "sync" |
| 1507 | + "time" |
| 1508 | + |
| 1509 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 1510 | + "github.com/google/uuid" |
| 1511 | +) |
| 1512 | + |
| 1513 | +type ServiceOptions struct { |
| 1514 | + RelayURL, Secret, StatePath string |
| 1515 | + Rooms []string |
| 1516 | + Process ProcessOptions |
| 1517 | + Timeout time.Duration |
| 1518 | + Log func(error) |
| 1519 | +} |
| 1520 | +type service struct { |
| 1521 | + options ServiceOptions |
| 1522 | + relay RelayClient |
| 1523 | + queue *Runner |
| 1524 | + ctx context.Context |
| 1525 | + mu sync.Mutex |
| 1526 | + decisions map[string]chan event.Event |
| 1527 | +} |
| 1528 | + |
| 1529 | +func Serve(ctx context.Context, opts ServiceOptions) error { |
| 1530 | + if opts.Timeout <= 0 { |
| 1531 | + opts.Timeout = 30 * time.Minute |
| 1532 | + } |
| 1533 | + pubkey, err := event.PublicKey(opts.Secret) |
| 1534 | + if err != nil { |
| 1535 | + return err |
| 1536 | + } |
| 1537 | + 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}} |
| 1538 | + queue, err := New(Options{AllowedRooms: opts.Rooms, StatePath: opts.StatePath, Handle: s.handle, OnError: opts.Log}) |
| 1539 | + if err != nil { |
| 1540 | + return err |
| 1541 | + } |
| 1542 | + s.queue = queue |
| 1543 | + s.relay.OnEvent = s.receive |
| 1544 | + // Start queues only after a connection is established, so journal recovery |
| 1545 | + // can publish status before it invokes the agent process. |
| 1546 | + s.relay.OnReady = func() { queue.Start(ctx) } |
| 1547 | + defer queue.Stop() |
| 1548 | + err = s.relay.Run(ctx) |
| 1549 | + if errors.Is(err, context.Canceled) { |
| 1550 | + return nil |
| 1551 | + } |
| 1552 | + return err |
| 1553 | +} |
| 1554 | +func (s *service) receive(e event.Event) { |
| 1555 | + s.mu.Lock() |
| 1556 | + for _, id := range event.TagValues(e, "e") { |
| 1557 | + if wait := s.decisions[id]; wait != nil { |
| 1558 | + select { |
| 1559 | + case wait <- e: |
| 1560 | + default: |
| 1561 | + } |
| 1562 | + } |
| 1563 | + } |
| 1564 | + s.mu.Unlock() |
| 1565 | + m, ok := s.relay.Mention(e) |
| 1566 | + if !ok { |
| 1567 | + return |
| 1568 | + } |
| 1569 | + if isCancelCommand(m.Prompt) { |
| 1570 | + s.queue.CancelFrom(m.Room, m.Author) |
| 1571 | + return |
| 1572 | + } |
| 1573 | + if err := s.queue.Enqueue(m); err != nil && !errors.Is(err, ErrDuplicate) && s.options.Log != nil { |
| 1574 | + s.options.Log(err) |
| 1575 | + } |
| 1576 | +} |
| 1577 | +func (s *service) handle(parent context.Context, batch []Mention) error { |
| 1578 | + ctx, cancel := context.WithTimeout(parent, s.options.Timeout) |
| 1579 | + defer cancel() |
| 1580 | + first := batch[0] |
| 1581 | + 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)}} |
| 1582 | + var prompt strings.Builder |
| 1583 | + prompt.WriteString("These are messages addressed to you in a Nostr chat. Reply to the users' request. Room: " + first.Room + "\n\n") |
| 1584 | + for _, m := range batch { |
| 1585 | + tags = append(tags, []string{"i", m.EventID, "event"}) |
| 1586 | + fmt.Fprintf(&prompt, "Author: %s\nMessage: %s\n%s\n\n", m.Author, m.EventID, m.Prompt) |
| 1587 | + } |
| 1588 | + request, err := s.relay.Publish(ctx, event.Event{Kind: 5000, CreatedAt: first.CreatedAt, Tags: tags, Content: shortText(first.Prompt, 1000)}) |
| 1589 | + if err != nil { |
| 1590 | + return err |
| 1591 | + } |
| 1592 | + feedback := func(status, info string) error { |
| 1593 | + _, 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)}) |
| 1594 | + return err |
| 1595 | + } |
| 1596 | + if err := feedback("processing", "Working on your request"); err != nil { |
| 1597 | + return err |
| 1598 | + } |
| 1599 | + process := s.options.Process |
| 1600 | + var last time.Time |
| 1601 | + process.Progress = func(info string) { |
| 1602 | + if time.Since(last) < 2*time.Second { |
| 1603 | + return |
| 1604 | + } |
| 1605 | + last = time.Now() |
| 1606 | + if err := feedback("processing", info); err != nil { |
| 1607 | + cancel() |
| 1608 | + } |
| 1609 | + } |
| 1610 | + process.Permission = func(ctx context.Context, p Permission) (string, error) { return s.permission(ctx, batch, p) } |
| 1611 | + heartbeatCtx, stopHeartbeat := context.WithCancel(ctx) |
| 1612 | + heartbeatDone := make(chan struct{}) |
| 1613 | + go func() { defer close(heartbeatDone); s.heartbeat(heartbeatCtx, first.Room) }() |
| 1614 | + output, runErr := RunProcess(ctx, process, prompt.String()) |
| 1615 | + stopHeartbeat() |
| 1616 | + <-heartbeatDone |
| 1617 | + finishCtx, finish := context.WithTimeout(s.ctx, 10*time.Second) |
| 1618 | + defer finish() |
| 1619 | + finalStatus := "idle" |
| 1620 | + if runErr != nil { |
| 1621 | + finalStatus = "offline" |
| 1622 | + _ = s.publishPresence(finishCtx, first.Room, finalStatus) |
| 1623 | + message := "Agent stopped: " + runErr.Error() |
| 1624 | + if errors.Is(runErr, context.Canceled) { |
| 1625 | + message = "Canceled" |
| 1626 | + } |
| 1627 | + _, 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)}) |
| 1628 | + if publishErr != nil { |
| 1629 | + return errors.Join(runErr, publishErr) |
| 1630 | + } |
| 1631 | + return runErr |
| 1632 | + } |
| 1633 | + _ = s.publishPresence(finishCtx, first.Room, finalStatus) |
| 1634 | + if output == "" { |
| 1635 | + output = "Task completed." |
| 1636 | + } |
| 1637 | + raw, _ := json.Marshal(request) |
| 1638 | + _, 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}) |
| 1639 | + if err != nil { |
| 1640 | + return err |
| 1641 | + } |
| 1642 | + replyTags := [][]string{{"h", first.Room}, {"e", first.Root, "", "root"}} |
| 1643 | + for _, m := range batch { |
| 1644 | + if !slices.ContainsFunc(replyTags, func(tag []string) bool { return len(tag) > 1 && tag[0] == "p" && tag[1] == m.Author }) { |
| 1645 | + replyTags = append(replyTags, []string{"p", m.Author}) |
| 1646 | + } |
| 1647 | + } |
| 1648 | + _, err = s.relay.Publish(ctx, event.Event{Kind: 9, Tags: replyTags, Content: output}) |
| 1649 | + return err |
| 1650 | +} |
| 1651 | + |
| 1652 | +func isCancelCommand(prompt string) bool { |
| 1653 | + fields := strings.Fields(prompt) |
| 1654 | + return len(fields) > 0 && (fields[0] == "/cancel" || fields[0] == "/stop") |
| 1655 | +} |
| 1656 | + |
| 1657 | +func (s *service) publishPresence(ctx context.Context, room, status string) error { |
| 1658 | + _, err := s.relay.Publish(ctx, event.Event{Kind: 20001, Tags: [][]string{{"h", room}}, Content: status}) |
| 1659 | + return err |
| 1660 | +} |
| 1661 | +func (s *service) heartbeat(ctx context.Context, room string) { |
| 1662 | + timer := time.NewTicker(30 * time.Second) |
| 1663 | + defer timer.Stop() |
| 1664 | + for { |
| 1665 | + _, err := s.relay.Publish(ctx, event.Event{Kind: 20001, Tags: [][]string{{"h", room}}, Content: "working"}) |
| 1666 | + if err != nil { |
| 1667 | + return |
| 1668 | + } |
| 1669 | + select { |
| 1670 | + case <-ctx.Done(): |
| 1671 | + return |
| 1672 | + case <-timer.C: |
| 1673 | + } |
| 1674 | + } |
| 1675 | +} |
| 1676 | +func (s *service) permission(ctx context.Context, batch []Mention, p Permission) (string, error) { |
| 1677 | + allow, deny := "", "" |
| 1678 | + for _, option := range p.Options { |
| 1679 | + if option.Kind == "allow_once" { |
| 1680 | + allow = option.ID |
| 1681 | + } |
| 1682 | + if option.Kind == "reject_once" { |
| 1683 | + deny = option.ID |
| 1684 | + } |
| 1685 | + } |
| 1686 | + if allow == "" { |
| 1687 | + return deny, nil |
| 1688 | + } |
| 1689 | + first := batch[0] |
| 1690 | + expires := time.Now().Add(10 * time.Minute).Unix() |
| 1691 | + if deadline, ok := ctx.Deadline(); ok && deadline.Unix() < expires { |
| 1692 | + expires = deadline.Unix() |
| 1693 | + } |
| 1694 | + 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} |
| 1695 | + if err := event.Sign(&request, s.options.Secret); err != nil { |
| 1696 | + return "", err |
| 1697 | + } |
| 1698 | + wait := make(chan event.Event, 1) |
| 1699 | + s.mu.Lock() |
| 1700 | + s.decisions[request.ID] = wait |
| 1701 | + s.mu.Unlock() |
| 1702 | + defer func() { s.mu.Lock(); delete(s.decisions, request.ID); s.mu.Unlock() }() |
| 1703 | + if _, err := s.relay.Publish(ctx, request); err != nil { |
| 1704 | + return "", err |
| 1705 | + } |
| 1706 | + for { |
| 1707 | + select { |
| 1708 | + case <-ctx.Done(): |
| 1709 | + return "", ctx.Err() |
| 1710 | + case answer := <-wait: |
| 1711 | + if answer.PubKey != first.Author || answer.Kind != 7 { |
| 1712 | + continue |
| 1713 | + } |
| 1714 | + switch strings.TrimSpace(answer.Content) { |
| 1715 | + case "+", "": |
| 1716 | + return allow, nil |
| 1717 | + case "-": |
| 1718 | + return deny, nil |
| 1719 | + } |
| 1720 | + } |
| 1721 | + } |
| 1722 | +} |
| 1723 | +func shortText(value string, size int) string { |
| 1724 | + runes := []rune(value) |
| 1725 | + if len(runes) > size { |
| 1726 | + return string(runes[:size]) + "…" |
| 1727 | + } |
| 1728 | + return value |
| 1729 | +} |
| 1730 | diff --git a/internal/community/agent_grant_requests.go b/internal/community/agent_grant_requests.go |
| 1731 | new file mode 100644 |
| 1732 | index 0000000..8139e84 |
| 1733 | --- /dev/null |
| 1734 | +++ b/internal/community/agent_grant_requests.go |
| 1735 | @@ -0,0 +1,350 @@ |
| 1736 | +package community |
| 1737 | + |
| 1738 | +// Agent grant requests are deliberately ordinary NIP-22 comments. The |
| 1739 | +// request describes a narrow change to the current grant; the operator's |
| 1740 | +// signed 30392 replacement remains the authority. |
| 1741 | + |
| 1742 | +import ( |
| 1743 | + "context" |
| 1744 | + "database/sql" |
| 1745 | + "encoding/json" |
| 1746 | + "errors" |
| 1747 | + "fmt" |
| 1748 | + "io" |
| 1749 | + "reflect" |
| 1750 | + "slices" |
| 1751 | + "strings" |
| 1752 | + "unicode/utf8" |
| 1753 | + |
| 1754 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 1755 | +) |
| 1756 | + |
| 1757 | +const agentGrantReasonMax = 500 |
| 1758 | + |
| 1759 | +type grantRequestReader interface { |
| 1760 | + QueryRowContext(context.Context, string, ...any) *sql.Row |
| 1761 | +} |
| 1762 | + |
| 1763 | +func grantRequestOpen(ctx context.Context, db grantRequestReader, request event.Event, now int64) error { |
| 1764 | + if expires := event.Expiration(request); expires != 0 && expires <= now { |
| 1765 | + return errors.New("conflict: grant request has expired") |
| 1766 | + } |
| 1767 | + var denied bool |
| 1768 | + err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM events AS reaction |
| 1769 | + WHERE kind=7 AND pubkey=? AND trim(json_extract(raw,'$.content'))='-' AND EXISTS( |
| 1770 | + SELECT 1 FROM tags WHERE event_id=reaction.id AND name='e' AND value=?))`, event.Tag(request, "p"), request.ID).Scan(&denied) |
| 1771 | + if err != nil { |
| 1772 | + return err |
| 1773 | + } |
| 1774 | + if denied { |
| 1775 | + return errors.New("conflict: grant request was denied") |
| 1776 | + } |
| 1777 | + return nil |
| 1778 | +} |
| 1779 | + |
| 1780 | +type AgentGrantChanges struct { |
| 1781 | + Kinds []int `json:"kinds,omitempty"` |
| 1782 | + Rooms []string `json:"rooms,omitempty"` |
| 1783 | + Repos []AgentRepo `json:"repos,omitempty"` |
| 1784 | + Sites []AgentSite `json:"sites,omitempty"` |
| 1785 | + Wiki *string `json:"wiki,omitempty"` |
| 1786 | + Jobs *string `json:"jobs,omitempty"` |
| 1787 | + Rate *int `json:"rate,omitempty"` |
| 1788 | +} |
| 1789 | + |
| 1790 | +type AgentGrantRequest struct { |
| 1791 | + Base string `json:"base"` |
| 1792 | + Changes AgentGrantChanges `json:"changes"` |
| 1793 | +} |
| 1794 | + |
| 1795 | +type AgentGrantReview struct { |
| 1796 | + RequestID string `json:"request_id"` |
| 1797 | + Base string `json:"base"` |
| 1798 | + Agent string `json:"agent"` |
| 1799 | + Operator string `json:"operator"` |
| 1800 | + Before AgentGrant `json:"before"` |
| 1801 | + After AgentGrant `json:"after"` |
| 1802 | + Unsigned event.Event `json:"unsigned"` |
| 1803 | +} |
| 1804 | + |
| 1805 | +func IsAgentGrantRequest(e event.Event) bool { |
| 1806 | + return e.Kind == 1111 && event.Tag(e, "request") == "grant" |
| 1807 | +} |
| 1808 | + |
| 1809 | +func decodeGrantRequest(e event.Event) (AgentGrantRequest, error) { |
| 1810 | + if !IsAgentGrantRequest(e) { |
| 1811 | + return AgentGrantRequest{}, errors.New("invalid: not an agent grant request") |
| 1812 | + } |
| 1813 | + if strings.TrimSpace(e.Content) == "" || len([]rune(e.Content)) > agentGrantReasonMax { |
| 1814 | + return AgentGrantRequest{}, fmt.Errorf("invalid: grant request rationale must be 1 to %d characters", agentGrantReasonMax) |
| 1815 | + } |
| 1816 | + if !utf8.ValidString(e.Content) { |
| 1817 | + return AgentGrantRequest{}, errors.New("invalid: grant request rationale is not valid UTF-8") |
| 1818 | + } |
| 1819 | + var req AgentGrantRequest |
| 1820 | + dec := json.NewDecoder(strings.NewReader(event.Tag(e, "grant"))) |
| 1821 | + dec.DisallowUnknownFields() |
| 1822 | + if err := dec.Decode(&req); err != nil || req.Base == "" { |
| 1823 | + return AgentGrantRequest{}, errors.New("invalid: grant request needs a base and changes") |
| 1824 | + } |
| 1825 | + var extra any |
| 1826 | + if err := dec.Decode(&extra); err != io.EOF { |
| 1827 | + return AgentGrantRequest{}, errors.New("invalid: grant request JSON has trailing data") |
| 1828 | + } |
| 1829 | + allowed := map[string]bool{"request": true, "grant": true, "E": true, "K": true, "P": true, "e": true, "k": true, "p": true, "subject": true, "expiration": true} |
| 1830 | + seen := map[string]bool{} |
| 1831 | + for _, tag := range e.Tags { |
| 1832 | + if len(tag) == 0 || !allowed[tag[0]] { |
| 1833 | + return AgentGrantRequest{}, errors.New("invalid: grant request contains an unsupported tag") |
| 1834 | + } |
| 1835 | + if seen[tag[0]] { |
| 1836 | + return AgentGrantRequest{}, errors.New("invalid: grant request contains duplicate tags") |
| 1837 | + } |
| 1838 | + seen[tag[0]] = true |
| 1839 | + } |
| 1840 | + 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 { |
| 1841 | + return AgentGrantRequest{}, errors.New("invalid: grant request has no changes") |
| 1842 | + } |
| 1843 | + if err := validateGrantChanges(req.Changes); err != nil { |
| 1844 | + return AgentGrantRequest{}, err |
| 1845 | + } |
| 1846 | + return req, nil |
| 1847 | +} |
| 1848 | + |
| 1849 | +func validateGrantChanges(changes AgentGrantChanges) error { |
| 1850 | + for _, room := range changes.Rooms { |
| 1851 | + if !ValidRoomID(room) { |
| 1852 | + return errors.New("invalid: grant request room") |
| 1853 | + } |
| 1854 | + } |
| 1855 | + repos, sites := map[string]bool{}, map[string]bool{} |
| 1856 | + for _, repo := range changes.Repos { |
| 1857 | + key := repo.Owner + ":" + repo.Identifier |
| 1858 | + if repos[key] { |
| 1859 | + return errors.New("invalid: repeated repository in grant request") |
| 1860 | + } |
| 1861 | + repos[key] = true |
| 1862 | + } |
| 1863 | + for _, site := range changes.Sites { |
| 1864 | + if sites[site.Label] || site.TTLDays < 0 { |
| 1865 | + return errors.New("invalid: duplicate site or negative TTL in grant request") |
| 1866 | + } |
| 1867 | + sites[site.Label] = true |
| 1868 | + } |
| 1869 | + return nil |
| 1870 | +} |
| 1871 | + |
| 1872 | +func (s *Service) ReviewAgentGrantRequest(ctx context.Context, e event.Event, now int64) (AgentGrantReview, error) { |
| 1873 | + req, err := decodeGrantRequest(e) |
| 1874 | + if err != nil { |
| 1875 | + return AgentGrantReview{}, err |
| 1876 | + } |
| 1877 | + if err := grantRequestOpen(ctx, s.store.DB(), e, now); err != nil { |
| 1878 | + return AgentGrantReview{}, err |
| 1879 | + } |
| 1880 | + operator := event.Tag(e, "p") |
| 1881 | + if req.Base != event.Tag(e, "E") || req.Base != event.Tag(e, "e") { |
| 1882 | + return AgentGrantReview{}, errors.New("invalid: grant request base must match its NIP-22 references") |
| 1883 | + } |
| 1884 | + if len(event.TagValues(e, "p")) != 1 || operator == "" { |
| 1885 | + return AgentGrantReview{}, errors.New("invalid: grant request needs exactly one operator") |
| 1886 | + } |
| 1887 | + 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 { |
| 1888 | + return AgentGrantReview{}, errors.New("invalid: grant request needs matching NIP-22 grant references") |
| 1889 | + } |
| 1890 | + var raw string |
| 1891 | + var currentID string |
| 1892 | + var paused int |
| 1893 | + var revokedAt int64 |
| 1894 | + if err := s.store.DB().QueryRowContext(ctx, `SELECT event_id,paused,revoked_at FROM agent_grants WHERE agent=?`, e.PubKey).Scan(¤tID, &paused, &revokedAt); err != nil || currentID != req.Base { |
| 1895 | + return AgentGrantReview{}, errors.New("conflict: grant request is no longer for the current grant") |
| 1896 | + } |
| 1897 | + 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 { |
| 1898 | + if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, req.Base).Scan(&raw); err != nil { |
| 1899 | + return AgentGrantReview{}, errors.New("invalid: current agent grant not found") |
| 1900 | + } |
| 1901 | + } |
| 1902 | + var beforeEvent event.Event |
| 1903 | + if beforeEvent, err = event.Parse([]byte(raw)); err != nil { |
| 1904 | + return AgentGrantReview{}, err |
| 1905 | + } |
| 1906 | + review, err := s.reviewAgainstGrant(e, req, beforeEvent, now) |
| 1907 | + if err != nil { |
| 1908 | + return AgentGrantReview{}, err |
| 1909 | + } |
| 1910 | + review.Before.Paused = paused != 0 |
| 1911 | + review.Before.RevokedAt = revokedAt |
| 1912 | + if !review.Before.Active(now) { |
| 1913 | + return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant") |
| 1914 | + } |
| 1915 | + return review, nil |
| 1916 | +} |
| 1917 | + |
| 1918 | +func (s *Service) reviewAgainstGrant(e event.Event, req AgentGrantRequest, beforeEvent event.Event, now int64) (AgentGrantReview, error) { |
| 1919 | + operator := event.Tag(e, "p") |
| 1920 | + before, err := ParseAgentGrant(beforeEvent, now) |
| 1921 | + if err != nil { |
| 1922 | + return AgentGrantReview{}, err |
| 1923 | + } |
| 1924 | + if before.Agent != e.PubKey || before.Owner != operator || !before.Active(now) { |
| 1925 | + return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant") |
| 1926 | + } |
| 1927 | + afterEvent := beforeEvent |
| 1928 | + afterEvent.Tags = grantRequestTags(beforeEvent.Tags, req.Changes) |
| 1929 | + afterEvent.Tags = append(afterEvent.Tags, []string{"grant-request", e.ID}, []string{"grant-base", req.Base}, []string{"e", e.ID, "", "grant-request"}) |
| 1930 | + afterEvent.ID, afterEvent.Sig, afterEvent.CreatedAt = "", "", now |
| 1931 | + after, err := ParseAgentGrant(afterEvent, now) |
| 1932 | + if err != nil { |
| 1933 | + return AgentGrantReview{}, err |
| 1934 | + } |
| 1935 | + if reflect.DeepEqual(after.Scope, before.Scope) { |
| 1936 | + return AgentGrantReview{}, errors.New("invalid: grant request changes nothing") |
| 1937 | + } |
| 1938 | + return AgentGrantReview{RequestID: e.ID, Base: req.Base, Agent: before.Agent, Operator: operator, Before: before, After: after, Unsigned: afterEvent}, nil |
| 1939 | +} |
| 1940 | + |
| 1941 | +func (s *Service) ValidateAgentGrantReplacement(ctx context.Context, e event.Event, now int64) error { |
| 1942 | + return s.validateGrantReplacement(ctx, s.store.DB(), e, now) |
| 1943 | +} |
| 1944 | + |
| 1945 | +func (s *Service) validateGrantReplacement(ctx context.Context, tx grantRequestReader, e event.Event, now int64) error { |
| 1946 | + requestID := event.Tag(e, "grant-request") |
| 1947 | + baseID := event.Tag(e, "grant-base") |
| 1948 | + if requestID == "" || baseID == "" || !slices.Contains(event.TagValues(e, "e"), requestID) { |
| 1949 | + return errors.New("invalid: grant replacement needs grant request provenance") |
| 1950 | + } |
| 1951 | + var raw string |
| 1952 | + if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=1111`, requestID).Scan(&raw); err != nil { |
| 1953 | + return errors.New("invalid: grant request is not stored") |
| 1954 | + } |
| 1955 | + request, err := event.Parse([]byte(raw)) |
| 1956 | + if err != nil { |
| 1957 | + return err |
| 1958 | + } |
| 1959 | + if err := grantRequestOpen(ctx, tx, request, now); err != nil { |
| 1960 | + return err |
| 1961 | + } |
| 1962 | + var grantRaw string |
| 1963 | + if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, baseID, event.KIND_AGENT_GRANT).Scan(&grantRaw); err != nil { |
| 1964 | + if err := tx.QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, baseID).Scan(&grantRaw); err != nil { |
| 1965 | + return errors.New("invalid: current agent grant not found") |
| 1966 | + } |
| 1967 | + } |
| 1968 | + grantEvent, err := event.Parse([]byte(grantRaw)) |
| 1969 | + if err != nil { |
| 1970 | + return err |
| 1971 | + } |
| 1972 | + req, err := decodeGrantRequest(request) |
| 1973 | + if err != nil { |
| 1974 | + return err |
| 1975 | + } |
| 1976 | + review, err := s.reviewAgainstGrant(request, req, grantEvent, now) |
| 1977 | + if err != nil || review.Base != baseID || review.Operator != e.PubKey || review.Agent != event.Tag(e, "d") { |
| 1978 | + return errors.New("restricted: grant replacement does not match the reviewed request") |
| 1979 | + } |
| 1980 | + var paused int |
| 1981 | + var revokedAt int64 |
| 1982 | + if err := tx.QueryRowContext(ctx, `SELECT paused,revoked_at FROM agent_grants WHERE agent=?`, review.Agent).Scan(&paused, &revokedAt); err != nil { |
| 1983 | + return errors.New("conflict: agent grant is no longer current") |
| 1984 | + } |
| 1985 | + if paused != 0 || revokedAt != 0 || review.After.ExpiresAt <= now { |
| 1986 | + return errors.New("restricted: grant request is no longer active") |
| 1987 | + } |
| 1988 | + got, err := ParseAgentGrant(e, now) |
| 1989 | + if err != nil { |
| 1990 | + return err |
| 1991 | + } |
| 1992 | + 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 { |
| 1993 | + return errors.New("invalid: grant replacement scope differs from the reviewed request") |
| 1994 | + } |
| 1995 | + if !reflect.DeepEqual(e.Tags, review.Unsigned.Tags) { |
| 1996 | + return errors.New("invalid: grant replacement tags differ from the reviewed request") |
| 1997 | + } |
| 1998 | + var current string |
| 1999 | + if err := tx.QueryRowContext(ctx, `SELECT event_id FROM agent_grants WHERE agent=?`, got.Agent).Scan(¤t); err != nil || current != baseID { |
| 2000 | + return errors.New("conflict: agent grant changed since the request") |
| 2001 | + } |
| 2002 | + return nil |
| 2003 | +} |
| 2004 | + |
| 2005 | +func grantRequestTags(tags [][]string, changes AgentGrantChanges) [][]string { |
| 2006 | + replaceRepo := map[string]bool{} |
| 2007 | + for _, repo := range changes.Repos { |
| 2008 | + replaceRepo[repo.Owner+":"+repo.Identifier] = true |
| 2009 | + } |
| 2010 | + replaceSite := map[string]bool{} |
| 2011 | + for _, site := range changes.Sites { |
| 2012 | + replaceSite[site.Label] = true |
| 2013 | + } |
| 2014 | + copyTags := make([][]string, 0, len(tags)+len(changes.Kinds)+len(changes.Rooms)) |
| 2015 | + seenKinds := map[string]bool{} |
| 2016 | + seenRooms := map[string]bool{} |
| 2017 | + remove := map[string]bool{} |
| 2018 | + if changes.Wiki != nil { |
| 2019 | + remove["wiki"] = true |
| 2020 | + } |
| 2021 | + if changes.Jobs != nil { |
| 2022 | + remove["jobs"] = true |
| 2023 | + } |
| 2024 | + if changes.Rate != nil { |
| 2025 | + remove["rate"] = true |
| 2026 | + } |
| 2027 | + for _, tag := range tags { |
| 2028 | + skip := false |
| 2029 | + if len(tag) > 1 && tag[0] == "repo" { |
| 2030 | + parts := strings.SplitN(tag[1], ":", 3) |
| 2031 | + skip = len(parts) == 3 && replaceRepo[parts[0]+":"+parts[1]] |
| 2032 | + } |
| 2033 | + if len(tag) > 1 && tag[0] == "sites" { |
| 2034 | + skip = replaceSite[tag[1]] |
| 2035 | + } |
| 2036 | + 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") { |
| 2037 | + copyTags = append(copyTags, append([]string(nil), tag...)) |
| 2038 | + if len(tag) > 1 && tag[0] == "k" { |
| 2039 | + seenKinds[tag[1]] = true |
| 2040 | + } |
| 2041 | + if len(tag) > 1 && tag[0] == "room" { |
| 2042 | + seenRooms[tag[1]] = true |
| 2043 | + } |
| 2044 | + } |
| 2045 | + } |
| 2046 | + // Changes are patches: retain the old entries and add or replace only the |
| 2047 | + // requested keys. Reconstructing these fields also gives deterministic |
| 2048 | + // output when a request repeats an existing value. |
| 2049 | + for _, k := range changes.Kinds { |
| 2050 | + value := fmt.Sprint(k) |
| 2051 | + if !seenKinds[value] { |
| 2052 | + copyTags = append(copyTags, []string{"k", value}) |
| 2053 | + seenKinds[value] = true |
| 2054 | + } |
| 2055 | + } |
| 2056 | + for _, room := range changes.Rooms { |
| 2057 | + if !seenRooms[room] { |
| 2058 | + copyTags = append(copyTags, []string{"room", room}) |
| 2059 | + seenRooms[room] = true |
| 2060 | + } |
| 2061 | + } |
| 2062 | + for _, repo := range changes.Repos { |
| 2063 | + copyTags = append(copyTags, []string{"repo", repo.Owner + ":" + repo.Identifier + ":" + repo.Level}) |
| 2064 | + } |
| 2065 | + for _, site := range changes.Sites { |
| 2066 | + tag := []string{"sites", site.Label} |
| 2067 | + if site.TTLDays > 0 { |
| 2068 | + tag = append(tag, fmt.Sprintf("ttl=%d", site.TTLDays)) |
| 2069 | + } |
| 2070 | + if site.Encrypted { |
| 2071 | + tag = append(tag, "encrypted") |
| 2072 | + } |
| 2073 | + copyTags = append(copyTags, tag) |
| 2074 | + } |
| 2075 | + if changes.Wiki != nil { |
| 2076 | + copyTags = append(copyTags, []string{"wiki", *changes.Wiki}) |
| 2077 | + } |
| 2078 | + if changes.Jobs != nil { |
| 2079 | + copyTags = append(copyTags, []string{"jobs", *changes.Jobs}) |
| 2080 | + } |
| 2081 | + if changes.Rate != nil { |
| 2082 | + copyTags = append(copyTags, []string{"rate", fmt.Sprint(*changes.Rate)}) |
| 2083 | + } |
| 2084 | + return copyTags |
| 2085 | +} |
| 2086 | diff --git a/internal/community/agent_grant_requests_test.go b/internal/community/agent_grant_requests_test.go |
| 2087 | new file mode 100644 |
| 2088 | index 0000000..211c05a |
| 2089 | --- /dev/null |
| 2090 | +++ b/internal/community/agent_grant_requests_test.go |
| 2091 | @@ -0,0 +1,34 @@ |
| 2092 | +package community |
| 2093 | + |
| 2094 | +import ( |
| 2095 | + "reflect" |
| 2096 | + "testing" |
| 2097 | +) |
| 2098 | + |
| 2099 | +func TestGrantRequestTagsPatchOnlyRequestedEntries(t *testing.T) { |
| 2100 | + old := [][]string{ |
| 2101 | + {"d", "agent"}, {"p", "agent"}, {"expiration", "200"}, |
| 2102 | + {"k", "1"}, {"k", "9"}, {"room", "alpha"}, {"room", "beta"}, |
| 2103 | + {"repo", "owner:one:read"}, {"repo", "owner:two:maintain"}, |
| 2104 | + {"grant-request", "old"}, {"grant-base", "old"}, {"e", "old", "", "grant-request"}, |
| 2105 | + } |
| 2106 | + want := [][]string{ |
| 2107 | + {"d", "agent"}, {"p", "agent"}, {"expiration", "200"}, |
| 2108 | + {"k", "1"}, {"k", "9"}, {"room", "alpha"}, {"room", "beta"}, |
| 2109 | + {"repo", "owner:one:read"}, {"repo", "owner:two:maintain"}, |
| 2110 | + {"k", "11"}, |
| 2111 | + } |
| 2112 | + got := grantRequestTags(old, AgentGrantChanges{Kinds: []int{11}, Rooms: []string{"alpha"}}) |
| 2113 | + if !reflect.DeepEqual(got, want) { |
| 2114 | + t.Fatalf("patch tags = %#v, want %#v", got, want) |
| 2115 | + } |
| 2116 | +} |
| 2117 | + |
| 2118 | +func TestGrantRequestPatchKeepsOtherRepositoriesAndSites(t *testing.T) { |
| 2119 | + old := [][]string{{"repo", "owner:one:read"}, {"repo", "owner:two:maintain"}, {"sites", "one", "encrypted"}, {"sites", "two", "ttl=7"}} |
| 2120 | + changed := grantRequestTags(old, AgentGrantChanges{Repos: []AgentRepo{{Owner: "owner", Identifier: "one", Level: "maintain"}}, Sites: []AgentSite{{Label: "one", TTLDays: 3}}}) |
| 2121 | + want := [][]string{{"repo", "owner:two:maintain"}, {"sites", "two", "ttl=7"}, {"repo", "owner:one:maintain"}, {"sites", "one", "ttl=3"}} |
| 2122 | + if !reflect.DeepEqual(changed, want) { |
| 2123 | + t.Fatalf("patch changed unrelated entries: %v", changed) |
| 2124 | + } |
| 2125 | +} |
| 2126 | diff --git a/internal/community/agents.go b/internal/community/agents.go |
| 2127 | index 24173e8..7a9983f 100644 |
| 2128 | --- a/internal/community/agents.go |
| 2129 | +++ b/internal/community/agents.go |
| 2130 | @@ -506,6 +506,11 @@ func (s *Service) AgentGrant(ctx context.Context, pubkey string) (AgentGrant, bo |
| 2131 | func (s *Service) ApplyAgentEventTx(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error { |
| 2132 | switch e.Kind { |
| 2133 | case event.KIND_AGENT_GRANT: |
| 2134 | + if event.Tag(e, "grant-request") != "" || event.Tag(e, "grant-base") != "" { |
| 2135 | + if err := s.validateGrantReplacement(ctx, tx, e, now); err != nil { |
| 2136 | + return err |
| 2137 | + } |
| 2138 | + } |
| 2139 | grant, err := ParseAgentGrant(e, now) |
| 2140 | if err != nil { |
| 2141 | return err |
| 2142 | diff --git a/internal/daemon/approvals.go b/internal/daemon/approvals.go |
| 2143 | index 7ac0d3b..97c7457 100644 |
| 2144 | --- a/internal/daemon/approvals.go |
| 2145 | +++ b/internal/daemon/approvals.go |
| 2146 | @@ -13,6 +13,7 @@ import ( |
| 2147 | "strings" |
| 2148 | "time" |
| 2149 | |
| 2150 | + "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 2151 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2152 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 2153 | ) |
| 2154 | @@ -26,8 +27,8 @@ const ( |
| 2155 | ) |
| 2156 | |
| 2157 | // approvalTypes is the bounded vocabulary of the request tag. |
| 2158 | -var approvalTypes = []string{"approve", "decide", "question"} |
| 2159 | -var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot} |
| 2160 | +var approvalTypes = []string{"approve", "decide", "question", "grant"} |
| 2161 | +var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot, event.KIND_THREAD_REPLY} |
| 2162 | |
| 2163 | const ( |
| 2164 | approvalWikiProposal = "wiki-proposal" |
| 2165 | @@ -36,20 +37,22 @@ const ( |
| 2166 | |
| 2167 | // approvalItem is one request as the asked person sees it. |
| 2168 | type approvalItem struct { |
| 2169 | - ID string `json:"id"` |
| 2170 | - Kind int `json:"kind"` |
| 2171 | - Type string `json:"type"` |
| 2172 | - Asker string `json:"asker"` |
| 2173 | - Asked []string `json:"asked"` |
| 2174 | - Subject string `json:"subject,omitempty"` |
| 2175 | - Content string `json:"content"` |
| 2176 | - CreatedAt int64 `json:"created_at"` |
| 2177 | - Expires int64 `json:"expires,omitempty"` |
| 2178 | - State string `json:"state"` |
| 2179 | - Room string `json:"room,omitempty"` |
| 2180 | - About *approvalAbout `json:"about,omitempty"` |
| 2181 | - Answer *approvalAnswer `json:"answer,omitempty"` |
| 2182 | - Event event.Event `json:"event"` |
| 2183 | + ID string `json:"id"` |
| 2184 | + Kind int `json:"kind"` |
| 2185 | + Type string `json:"type"` |
| 2186 | + Asker string `json:"asker"` |
| 2187 | + Asked []string `json:"asked"` |
| 2188 | + Subject string `json:"subject,omitempty"` |
| 2189 | + Content string `json:"content"` |
| 2190 | + CreatedAt int64 `json:"created_at"` |
| 2191 | + Expires int64 `json:"expires,omitempty"` |
| 2192 | + State string `json:"state"` |
| 2193 | + Room string `json:"room,omitempty"` |
| 2194 | + About *approvalAbout `json:"about,omitempty"` |
| 2195 | + Answer *approvalAnswer `json:"answer,omitempty"` |
| 2196 | + Event event.Event `json:"event"` |
| 2197 | + Grant *community.AgentGrantReview `json:"grant,omitempty"` |
| 2198 | + GrantError string `json:"grant_error,omitempty"` |
| 2199 | } |
| 2200 | |
| 2201 | // approvalAbout is what the request refers to: a repository coordinate, an |
| 2202 | @@ -82,6 +85,9 @@ func approvalRequest(e event.Event) (string, bool) { |
| 2203 | if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 { |
| 2204 | return "", false |
| 2205 | } |
| 2206 | + if kind == "grant" && !community.IsAgentGrantRequest(e) { |
| 2207 | + return "", false |
| 2208 | + } |
| 2209 | return kind, true |
| 2210 | } |
| 2211 | |
| 2212 | @@ -166,6 +172,11 @@ func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now |
| 2213 | if err != nil { |
| 2214 | return nil, err |
| 2215 | } |
| 2216 | + grants, err := t.grantApprovalHistory(ctx, items) |
| 2217 | + if err != nil { |
| 2218 | + return nil, err |
| 2219 | + } |
| 2220 | + rows.Events = append(rows.Events, grants...) |
| 2221 | sortEventsNewestFirst(rows.Events) |
| 2222 | for _, row := range rows.Events { |
| 2223 | if _, isRequest := approvalRequest(row); isRequest { |
| 2224 | @@ -183,6 +194,10 @@ func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now |
| 2225 | // approvalSettle fills the answer and state of one request from its answers, |
| 2226 | // which arrive newest first. |
| 2227 | func approvalSettle(item *approvalItem, rows []event.Event, now int64) { |
| 2228 | + if item.Type == "grant" { |
| 2229 | + settleGrantApproval(item, rows, now) |
| 2230 | + return |
| 2231 | + } |
| 2232 | var reply *event.Event |
| 2233 | for i := range rows { |
| 2234 | row := rows[i] |
| 2235 | @@ -192,7 +207,7 @@ func approvalSettle(item *approvalItem, rows []event.Event, now int64) { |
| 2236 | item.State = "answered" |
| 2237 | return |
| 2238 | } |
| 2239 | - } else if reply == nil { |
| 2240 | + } else if row.Kind == kindComment && reply == nil { |
| 2241 | reply = &row |
| 2242 | } |
| 2243 | } |
| 2244 | @@ -230,6 +245,7 @@ func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storag |
| 2245 | } |
| 2246 | for i := range items { |
| 2247 | approvalSettle(&items[i], answers[items[i].ID], now) |
| 2248 | + t.reviewGrantApproval(ctx, &items[i], now) |
| 2249 | } |
| 2250 | wikiItems, wikiMore, err := t.wikiApprovalItems(ctx, pubkey, before, limit) |
| 2251 | if err != nil { |
| 2252 | @@ -288,6 +304,10 @@ func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice { |
| 2253 | if kind != "question" { |
| 2254 | actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}} |
| 2255 | } |
| 2256 | + if kind == "grant" { |
| 2257 | + body = shortKey(e.PubKey) + " requests an access change: " + excerpt(e.Content) |
| 2258 | + actions = []pushAction{{Action: "review", Title: "Review access"}} |
| 2259 | + } |
| 2260 | var notices []pushNotice |
| 2261 | for _, recipient := range approvalAsked(e) { |
| 2262 | notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions}) |
| 2263 | diff --git a/internal/daemon/approvals_browse.go b/internal/daemon/approvals_browse.go |
| 2264 | index 1bf34b3..281a746 100644 |
| 2265 | --- a/internal/daemon/approvals_browse.go |
| 2266 | +++ b/internal/daemon/approvals_browse.go |
| 2267 | @@ -179,6 +179,7 @@ func (t *Tenant) browseApproval(ctx context.Context, actor, id string) (any, err |
| 2268 | return nil, err |
| 2269 | } |
| 2270 | approvalSettle(&item, answers[item.ID], now) |
| 2271 | + t.reviewGrantApproval(ctx, &item, now) |
| 2272 | history := answers[item.ID] |
| 2273 | if history == nil { |
| 2274 | history = []event.Event{} |
| 2275 | diff --git a/internal/daemon/chat_activity.go b/internal/daemon/chat_activity.go |
| 2276 | new file mode 100644 |
| 2277 | index 0000000..037210b |
| 2278 | --- /dev/null |
| 2279 | +++ b/internal/daemon/chat_activity.go |
| 2280 | @@ -0,0 +1,123 @@ |
| 2281 | +package daemon |
| 2282 | + |
| 2283 | +import ( |
| 2284 | + "context" |
| 2285 | + "encoding/json" |
| 2286 | + "errors" |
| 2287 | + "time" |
| 2288 | + |
| 2289 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2290 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 2291 | +) |
| 2292 | + |
| 2293 | +func (t *Tenant) browseChatActivity(ctx context.Context, actor string, params []json.RawMessage) (any, error) { |
| 2294 | + var q struct { |
| 2295 | + Room string `json:"room"` |
| 2296 | + Root string `json:"root"` |
| 2297 | + } |
| 2298 | + if len(params) != 1 || json.Unmarshal(params[0], &q) != nil || q.Room == "" { |
| 2299 | + return nil, errors.New("invalid: chat activity room") |
| 2300 | + } |
| 2301 | + if _, _, err := t.roomFor(ctx, actor, q.Room); err != nil { |
| 2302 | + return nil, err |
| 2303 | + } |
| 2304 | + if q.Root != "" { |
| 2305 | + root, err := t.roomThreadRoot(ctx, actor, q.Room, q.Root) |
| 2306 | + if err != nil { |
| 2307 | + return nil, err |
| 2308 | + } |
| 2309 | + q.Root = root.ID |
| 2310 | + } |
| 2311 | + rows := []event.Event{} |
| 2312 | + for _, filter := range []event.Filter{ |
| 2313 | + {Kinds: approvalKinds, Tags: map[string][]string{"h": {q.Room}}}, |
| 2314 | + {Kinds: jobRequestKinds, Tags: map[string][]string{"h": {q.Room}}}, |
| 2315 | + } { |
| 2316 | + page, err := t.store.Query(ctx, filter, storage.QueryOptions{Access: storage.Access{PubKeys: []string{actor}}, Limit: 1000}) |
| 2317 | + if err != nil { |
| 2318 | + return nil, err |
| 2319 | + } |
| 2320 | + rows = append(rows, page.Events...) |
| 2321 | + } |
| 2322 | + session := browseSession(t, actor) |
| 2323 | + jobs, approvals := []jobItem{}, []approvalItem{} |
| 2324 | + now := time.Now().Unix() |
| 2325 | + roots := map[string]string{} |
| 2326 | + for _, row := range rows { |
| 2327 | + _, isApproval := approvalRequest(row) |
| 2328 | + if !event.IsJobRequest(row.Kind) && !isApproval { |
| 2329 | + continue |
| 2330 | + } |
| 2331 | + if !t.gate.CanSee(ctx, row, session, nil) { |
| 2332 | + continue |
| 2333 | + } |
| 2334 | + if q.Root != "" && !t.activityInThread(ctx, actor, q.Root, row, roots) { |
| 2335 | + continue |
| 2336 | + } |
| 2337 | + if event.IsJobRequest(row.Kind) && t.Policy().Features.Jobs && (event.Expiration(row) == 0 || event.Expiration(row) > now) { |
| 2338 | + jobs = append(jobs, jobItemFrom(row)) |
| 2339 | + } else if kind, ok := approvalRequest(row); ok && (row.PubKey == actor || containsString(approvalAsked(row), actor)) { |
| 2340 | + approvals = append(approvals, approvalItemFrom(row, kind, t.publicURL)) |
| 2341 | + } |
| 2342 | + } |
| 2343 | + // Keep the inline panel bounded even when a busy room has accumulated a |
| 2344 | + // large amount of historical task and approval traffic. The store returns |
| 2345 | + // each filter newest first, so retaining the first 50 preserves the useful |
| 2346 | + // recent context while the dedicated views remain available for history. |
| 2347 | + if len(jobs) > 50 { |
| 2348 | + jobs = jobs[:50] |
| 2349 | + } |
| 2350 | + if len(approvals) > 50 { |
| 2351 | + approvals = approvals[:50] |
| 2352 | + } |
| 2353 | + answers, err := t.jobAnswers(ctx, jobs, session, now) |
| 2354 | + if err != nil { |
| 2355 | + return nil, err |
| 2356 | + } |
| 2357 | + for i := range jobs { |
| 2358 | + jobSettle(&jobs[i], answers[jobs[i].ID]) |
| 2359 | + } |
| 2360 | + decisions, err := t.approvalAnswers(ctx, approvals, now) |
| 2361 | + if err != nil { |
| 2362 | + return nil, err |
| 2363 | + } |
| 2364 | + for i := range approvals { |
| 2365 | + approvalSettle(&approvals[i], decisions[approvals[i].ID], now) |
| 2366 | + } |
| 2367 | + return map[string]any{"jobs": jobs, "approvals": approvals}, nil |
| 2368 | +} |
| 2369 | + |
| 2370 | +func (b backend) ReadChatActivity(ctx context.Context, actor, room, root string) (any, error) { |
| 2371 | + raw, err := json.Marshal(map[string]string{"room": room, "root": root}) |
| 2372 | + if err != nil { |
| 2373 | + return nil, err |
| 2374 | + } |
| 2375 | + return b.tenant.Execute(ctx, actor, "browsechatactivity", []json.RawMessage{raw}) |
| 2376 | +} |
| 2377 | + |
| 2378 | +// Resolve parent-only references once per parent while keeping unrelated tasks |
| 2379 | +// out of the thread. roomThreadRoot also enforces room and reader access. |
| 2380 | +func (t *Tenant) activityInThread(ctx context.Context, actor, root string, row event.Event, roots map[string]string) bool { |
| 2381 | + if row.ID == root { |
| 2382 | + return true |
| 2383 | + } |
| 2384 | + reference := event.RoomReplyRoot(row) |
| 2385 | + if reference == "" && event.IsJobRequest(row.Kind) { |
| 2386 | + reference = event.Tag(row, "e") |
| 2387 | + } |
| 2388 | + if reference == root { |
| 2389 | + return true |
| 2390 | + } |
| 2391 | + if reference == "" { |
| 2392 | + return false |
| 2393 | + } |
| 2394 | + canonical, known := roots[reference] |
| 2395 | + if !known { |
| 2396 | + parent, err := t.roomThreadRoot(ctx, actor, event.Tag(row, "h"), reference) |
| 2397 | + if err == nil { |
| 2398 | + canonical = parent.ID |
| 2399 | + } |
| 2400 | + roots[reference] = canonical |
| 2401 | + } |
| 2402 | + return canonical == root |
| 2403 | +} |
| 2404 | diff --git a/internal/daemon/chat_activity_test.go b/internal/daemon/chat_activity_test.go |
| 2405 | new file mode 100644 |
| 2406 | index 0000000..caf0620 |
| 2407 | --- /dev/null |
| 2408 | +++ b/internal/daemon/chat_activity_test.go |
| 2409 | @@ -0,0 +1,88 @@ |
| 2410 | +package daemon |
| 2411 | + |
| 2412 | +import ( |
| 2413 | + "encoding/json" |
| 2414 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2415 | + "testing" |
| 2416 | +) |
| 2417 | + |
| 2418 | +func TestChatActivitySharesApprovalStateAndScopesRooms(t *testing.T) { |
| 2419 | + h := newRoomHarness(t) |
| 2420 | + request := h.must("alice", 5000, [][]string{{"h", "main"}, {"p", h.keys["bob"]}, {"subject", "Build preview"}}, "Build preview") |
| 2421 | + h.must("bob", 7000, [][]string{{"h", "main"}, {"e", request.ID}, {"p", h.keys["alice"]}, {"status", "processing", "Running checks"}}, "2 of 3 checks complete") |
| 2422 | + decision := h.must("alice", 9, [][]string{{"h", "main"}, {"request", "approve"}, {"p", h.keys["bob"]}, {"subject", "Publish preview"}}, "Publish it?") |
| 2423 | + read := func(who, room string) map[string]any { |
| 2424 | + t.Helper() |
| 2425 | + raw, err := h.tenant.Execute(h.ctx, h.keys[who], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": room})}) |
| 2426 | + if err != nil { |
| 2427 | + t.Fatal(err) |
| 2428 | + } |
| 2429 | + return raw.(map[string]any) |
| 2430 | + } |
| 2431 | + data := read("bob", "main") |
| 2432 | + jobs := data["jobs"].([]jobItem) |
| 2433 | + if len(jobs) != 1 || jobs[0].Status != "processing" { |
| 2434 | + t.Fatalf("jobs=%v", jobs) |
| 2435 | + } |
| 2436 | + approvals := data["approvals"].([]approvalItem) |
| 2437 | + if len(approvals) != 1 || approvals[0].State != "open" { |
| 2438 | + t.Fatalf("approvals=%v", approvals) |
| 2439 | + } |
| 2440 | + h.must("bob", 7, [][]string{{"h", "main"}, {"e", decision.ID}, {"p", h.keys["alice"]}}, "+") |
| 2441 | + data = read("bob", "main") |
| 2442 | + if data["approvals"].([]approvalItem)[0].State != "answered" { |
| 2443 | + t.Fatal("inline approval not settled") |
| 2444 | + } |
| 2445 | + detail, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browseapproval", []json.RawMessage{rawJSON(map[string]string{"id": decision.ID})}) |
| 2446 | + if err != nil { |
| 2447 | + t.Fatal(err) |
| 2448 | + } |
| 2449 | + if detail.(map[string]any)["item"].(approvalItem).State != "answered" { |
| 2450 | + t.Fatal("Approvals disagrees") |
| 2451 | + } |
| 2452 | + h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-chat"}, {"name", "Private"}, {"visibility", "members"}}, "") |
| 2453 | + private := h.must("alice", 5000, [][]string{{"h", "private-chat"}, {"p", h.keys["alice"]}}, "Private work") |
| 2454 | + if _, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": "private-chat"})}); err == nil { |
| 2455 | + t.Fatal("outsider read private activity") |
| 2456 | + } |
| 2457 | + for _, job := range read("bob", "main")["jobs"].([]jobItem) { |
| 2458 | + if job.ID == private.ID { |
| 2459 | + t.Fatal("private job leaked") |
| 2460 | + } |
| 2461 | + } |
| 2462 | + if _, err := h.publish("alice", 7000, [][]string{{"e", private.ID}, {"p", h.keys["alice"]}, {"status", "processing"}}, "private progress"); err == nil { |
| 2463 | + t.Fatal("unscoped progress accepted for a private room task") |
| 2464 | + } |
| 2465 | +} |
| 2466 | +func TestChatPresenceIsEphemeralAndRoomAuthorized(t *testing.T) { |
| 2467 | + h := newRoomHarness(t) |
| 2468 | + h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-chat"}, {"visibility", "members"}}, "") |
| 2469 | + for _, kind := range []int{20001, 20002} { |
| 2470 | + e := h.must("alice", kind, [][]string{{"h", "private-chat"}}, "typing") |
| 2471 | + if rows := h.query("alice", event.Filter{IDs: []string{e.ID}}); len(rows) != 0 { |
| 2472 | + t.Fatal("presence persisted") |
| 2473 | + } |
| 2474 | + if _, err := h.publish("bob", kind, [][]string{{"h", "private-chat"}}, "typing"); err == nil { |
| 2475 | + t.Fatal("outsider presence accepted") |
| 2476 | + } |
| 2477 | + } |
| 2478 | +} |
| 2479 | + |
| 2480 | +func TestChatActivityIncludesParentOnlyRequestsInCanonicalThread(t *testing.T) { |
| 2481 | + h := newRoomHarness(t) |
| 2482 | + root := h.must("alice", 9, [][]string{{"h", "main"}}, "Root") |
| 2483 | + parent := h.must("bob", 9, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, "Parent") |
| 2484 | + approval := h.must("alice", 9, [][]string{{"h", "main"}, {"e", parent.ID, "", "reply"}, {"request", "approve"}, {"p", h.keys["bob"]}}, "Approve the nested action?") |
| 2485 | + job := h.must("alice", 5000, [][]string{{"h", "main"}, {"e", parent.ID}, {"p", h.keys["bob"]}}, "Nested job") |
| 2486 | + other := h.must("alice", 9, [][]string{{"h", "main"}}, "Another root") |
| 2487 | + h.must("alice", 9, [][]string{{"h", "main"}, {"e", other.ID, "", "root"}, {"request", "approve"}, {"p", h.keys["bob"]}}, "Unrelated action") |
| 2488 | + raw, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": "main", "root": root.ID})}) |
| 2489 | + if err != nil { |
| 2490 | + t.Fatal(err) |
| 2491 | + } |
| 2492 | + data := raw.(map[string]any) |
| 2493 | + approvals, jobs := data["approvals"].([]approvalItem), data["jobs"].([]jobItem) |
| 2494 | + if len(approvals) != 1 || approvals[0].ID != approval.ID || len(jobs) != 1 || jobs[0].ID != job.ID { |
| 2495 | + t.Fatalf("activity omitted nested request or included another thread: %v", data) |
| 2496 | + } |
| 2497 | +} |
| 2498 | diff --git a/internal/daemon/chat_preferences.go b/internal/daemon/chat_preferences.go |
| 2499 | new file mode 100644 |
| 2500 | index 0000000..cdf9eea |
| 2501 | --- /dev/null |
| 2502 | +++ b/internal/daemon/chat_preferences.go |
| 2503 | @@ -0,0 +1,74 @@ |
| 2504 | +package daemon |
| 2505 | + |
| 2506 | +import ( |
| 2507 | + "context" |
| 2508 | + "database/sql" |
| 2509 | + "encoding/json" |
| 2510 | + "errors" |
| 2511 | + "io" |
| 2512 | + "net/http" |
| 2513 | + "strings" |
| 2514 | +) |
| 2515 | + |
| 2516 | +const chatSharePresenceSetting = "account.chat.share_presence." |
| 2517 | + |
| 2518 | +type chatPreferences struct { |
| 2519 | + SharePresence bool `json:"share_presence"` |
| 2520 | +} |
| 2521 | + |
| 2522 | +func chatPreferenceKey(actor string) string { |
| 2523 | + return chatSharePresenceSetting + actor |
| 2524 | +} |
| 2525 | + |
| 2526 | +func (t *Tenant) chatPreferencesHTTP(w http.ResponseWriter, r *http.Request) { |
| 2527 | + w.Header().Set("Cache-Control", "private, no-store") |
| 2528 | + r.Body = http.MaxBytesReader(w, r.Body, 4096) |
| 2529 | + actor, err := t.resolveUIActor(r) |
| 2530 | + if err != nil || actor == "" { |
| 2531 | + http.Error(w, "authentication required", http.StatusUnauthorized) |
| 2532 | + return |
| 2533 | + } |
| 2534 | + key := chatPreferenceKey(actor) |
| 2535 | + if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| 2536 | + preferences := chatPreferences{} |
| 2537 | + if err := t.store.GetSetting(r.Context(), key, &preferences); err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 2538 | + http.Error(w, "could not read chat preferences", http.StatusInternalServerError) |
| 2539 | + return |
| 2540 | + } |
| 2541 | + writeJSON(w, http.StatusOK, preferences) |
| 2542 | + return |
| 2543 | + } |
| 2544 | + if r.Method != http.MethodPost { |
| 2545 | + w.Header().Set("Allow", "GET, HEAD, POST") |
| 2546 | + w.WriteHeader(http.StatusMethodNotAllowed) |
| 2547 | + return |
| 2548 | + } |
| 2549 | + var preferences chatPreferences |
| 2550 | + decoder := json.NewDecoder(r.Body) |
| 2551 | + decoder.DisallowUnknownFields() |
| 2552 | + if err := decoder.Decode(&preferences); err != nil { |
| 2553 | + http.Error(w, "invalid chat preferences", http.StatusBadRequest) |
| 2554 | + return |
| 2555 | + } |
| 2556 | + var extra any |
| 2557 | + if err := decoder.Decode(&extra); err != io.EOF { |
| 2558 | + http.Error(w, "invalid chat preferences", http.StatusBadRequest) |
| 2559 | + return |
| 2560 | + } |
| 2561 | + if err := t.store.PutSetting(r.Context(), key, preferences); err != nil { |
| 2562 | + http.Error(w, "could not save chat preferences", http.StatusInternalServerError) |
| 2563 | + return |
| 2564 | + } |
| 2565 | + writeJSON(w, http.StatusOK, preferences) |
| 2566 | +} |
| 2567 | + |
| 2568 | +func (t *Tenant) chatSharePresence(ctx context.Context, actor string) bool { |
| 2569 | + if strings.TrimSpace(actor) == "" { |
| 2570 | + return false |
| 2571 | + } |
| 2572 | + var preferences chatPreferences |
| 2573 | + if err := t.store.GetSetting(ctx, chatPreferenceKey(actor), &preferences); err != nil { |
| 2574 | + return false |
| 2575 | + } |
| 2576 | + return preferences.SharePresence |
| 2577 | +} |
| 2578 | diff --git a/internal/daemon/chat_preferences_test.go b/internal/daemon/chat_preferences_test.go |
| 2579 | new file mode 100644 |
| 2580 | index 0000000..d3d4a3a |
| 2581 | --- /dev/null |
| 2582 | +++ b/internal/daemon/chat_preferences_test.go |
| 2583 | @@ -0,0 +1,59 @@ |
| 2584 | +package daemon |
| 2585 | + |
| 2586 | +import ( |
| 2587 | + "context" |
| 2588 | + "io" |
| 2589 | + "net/http" |
| 2590 | + "net/http/httptest" |
| 2591 | + "strings" |
| 2592 | + "testing" |
| 2593 | +) |
| 2594 | + |
| 2595 | +func TestChatPreferencesWritesBindAccountAndSignedBody(t *testing.T) { |
| 2596 | + app, tenant := testTenant(t) |
| 2597 | + owner := tenant.Policy().Owner |
| 2598 | + if err := tenant.store.PutSetting(context.Background(), chatPreferenceKey(owner), chatPreferences{SharePresence: true}); err != nil { |
| 2599 | + t.Fatal(err) |
| 2600 | + } |
| 2601 | + other := strings.Repeat("0", 63) + "2" |
| 2602 | + read := httptest.NewRequest("GET", "http://relay.test/chat/preferences?actor="+owner, nil) |
| 2603 | + signRequestWithSecret(t, read, "", other) |
| 2604 | + result := httptest.NewRecorder() |
| 2605 | + app.ServeHTTP(result, read) |
| 2606 | + if result.Code != 200 || result.Body.String() != `{"share_presence":false}` { |
| 2607 | + t.Fatalf("cross-account read: %d %s", result.Code, result.Body.String()) |
| 2608 | + } |
| 2609 | + for _, test := range []struct { |
| 2610 | + name, body string |
| 2611 | + signed bool |
| 2612 | + code int |
| 2613 | + }{ |
| 2614 | + {"unsigned", `{"share_presence":false}`, false, 401}, |
| 2615 | + {"actor injection", `{"actor":"` + owner + `","share_presence":false}`, true, 400}, |
| 2616 | + {"trailing object", `{"share_presence":false}{}`, true, 400}, |
| 2617 | + {"invalid type", `{"share_presence":"true"}`, true, 400}, |
| 2618 | + } { |
| 2619 | + t.Run(test.name, func(t *testing.T) { |
| 2620 | + req := httptest.NewRequest("POST", "http://relay.test/chat/preferences", strings.NewReader(test.body)) |
| 2621 | + if test.signed { |
| 2622 | + signRequestWithSecret(t, req, test.body, other) |
| 2623 | + } |
| 2624 | + res := httptest.NewRecorder() |
| 2625 | + app.ServeHTTP(res, req) |
| 2626 | + if res.Code != test.code { |
| 2627 | + t.Fatalf("status %d: %s", res.Code, res.Body.String()) |
| 2628 | + } |
| 2629 | + }) |
| 2630 | + } |
| 2631 | + altered := httptest.NewRequest("POST", "http://relay.test/chat/preferences", strings.NewReader(`{"share_presence":true}`)) |
| 2632 | + signRequest(t, altered, `{"share_presence":true}`) |
| 2633 | + altered.Body = io.NopCloser(strings.NewReader(`{"share_presence":false}`)) |
| 2634 | + rejected := httptest.NewRecorder() |
| 2635 | + app.ServeHTTP(rejected, altered) |
| 2636 | + if rejected.Code != http.StatusUnauthorized { |
| 2637 | + t.Fatalf("altered signed body: %d", rejected.Code) |
| 2638 | + } |
| 2639 | + if !tenant.chatSharePresence(context.Background(), owner) { |
| 2640 | + t.Fatal("another account or invalid signature changed owner preference") |
| 2641 | + } |
| 2642 | +} |
| 2643 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 2644 | index d9fc298..aa7186a 100644 |
| 2645 | --- a/internal/daemon/client_browse.go |
| 2646 | +++ b/internal/daemon/client_browse.go |
| 2647 | @@ -34,7 +34,7 @@ type clientBrowseRequest struct { |
| 2648 | } |
| 2649 | |
| 2650 | func clientBrowseMethod(method string) bool { |
| 2651 | - return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || directMessagesBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method) |
| 2652 | + return method == "browsechatactivity" || wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || directMessagesBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method) |
| 2653 | } |
| 2654 | |
| 2655 | func (t *Tenant) browseRead(ctx context.Context, actor string) error { |
| 2656 | @@ -58,6 +58,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params |
| 2657 | if err := t.browseRead(ctx, actor); err != nil { |
| 2658 | return nil, err |
| 2659 | } |
| 2660 | + if method == "browsechatactivity" { |
| 2661 | + return t.browseChatActivity(ctx, actor, params) |
| 2662 | + } |
| 2663 | if wikiBrowseMethod(method) { |
| 2664 | return t.executeWiki(ctx, actor, method, params) |
| 2665 | } |
| 2666 | diff --git a/internal/daemon/grant_admission_test.go b/internal/daemon/grant_admission_test.go |
| 2667 | new file mode 100644 |
| 2668 | index 0000000..e7e8ed7 |
| 2669 | --- /dev/null |
| 2670 | +++ b/internal/daemon/grant_admission_test.go |
| 2671 | @@ -0,0 +1,194 @@ |
| 2672 | +package daemon |
| 2673 | + |
| 2674 | +import ( |
| 2675 | + "context" |
| 2676 | + "encoding/json" |
| 2677 | + "strconv" |
| 2678 | + "strings" |
| 2679 | + "testing" |
| 2680 | + "time" |
| 2681 | + |
| 2682 | + "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 2683 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2684 | +) |
| 2685 | + |
| 2686 | +func grantRequestEvent(t *testing.T, secret string, grant event.Event, at int64, content, grantJSON string, tags ...[]string) event.Event { |
| 2687 | + t.Helper() |
| 2688 | + 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}} |
| 2689 | + return signedEvent(t, secret, 1111, at, append(base, tags...), content) |
| 2690 | +} |
| 2691 | + |
| 2692 | +func grantRequestJSON(t *testing.T, grant event.Event, changes community.AgentGrantChanges) string { |
| 2693 | + t.Helper() |
| 2694 | + raw, err := json.Marshal(community.AgentGrantRequest{Base: grant.ID, Changes: changes}) |
| 2695 | + if err != nil { |
| 2696 | + t.Fatal(err) |
| 2697 | + } |
| 2698 | + return string(raw) |
| 2699 | +} |
| 2700 | + |
| 2701 | +func setGrantTag(t *testing.T, e *event.Event, name, value string) { |
| 2702 | + t.Helper() |
| 2703 | + for _, tag := range e.Tags { |
| 2704 | + if len(tag) > 1 && tag[0] == name { |
| 2705 | + tag[1] = value |
| 2706 | + return |
| 2707 | + } |
| 2708 | + } |
| 2709 | + t.Fatalf("grant replacement has no %s tag", name) |
| 2710 | +} |
| 2711 | + |
| 2712 | +func TestGrantRequestAdmissionRequiresAgentAndValidCurrentGrant(t *testing.T) { |
| 2713 | + _, tenant := testTenant(t) |
| 2714 | + now := time.Now().Unix() |
| 2715 | + agent, _ := event.PublicKey(testAgentSecret) |
| 2716 | + member, _ := event.PublicKey(testMemberSecret) |
| 2717 | + grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}, []string{"k", "1111"}) |
| 2718 | + if err := publishAs(t, tenant, grant); err != nil { |
| 2719 | + t.Fatal(err) |
| 2720 | + } |
| 2721 | + reqJSON := grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}}) |
| 2722 | + human := grantRequestEvent(t, testMemberSecret, grant, now, "please", reqJSON) |
| 2723 | + if err := publishAs(t, tenant, human); err == nil { |
| 2724 | + t.Fatal("human request accepted") |
| 2725 | + } |
| 2726 | + |
| 2727 | + for name, mutate := range map[string]func(event.Event) event.Event{ |
| 2728 | + "rationale over limit": func(e event.Event) event.Event { e.Content = strings.Repeat("x", 501); return e }, |
| 2729 | + "rationale whitespace": func(e event.Event) event.Event { e.Content = " \t\n "; return e }, |
| 2730 | + "duplicate operator": func(e event.Event) event.Event { e.Tags = append(e.Tags, []string{"p", grant.PubKey}); return e }, |
| 2731 | + "wrong operator": func(e event.Event) event.Event { e.Tags[4][1] = member; e.Tags[7][1] = member; return e }, |
| 2732 | + "wrong base": func(e event.Event) event.Event { |
| 2733 | + e.Tags[2][1] = strings.Repeat("f", 64) |
| 2734 | + e.Tags[5][1] = strings.Repeat("f", 64) |
| 2735 | + return e |
| 2736 | + }, |
| 2737 | + "unknown change": func(e event.Event) event.Event { |
| 2738 | + e.Tags[1][1] = `{"base":"` + grant.ID + `","changes":{"nope":true}}` |
| 2739 | + return e |
| 2740 | + }, |
| 2741 | + "noop": func(e event.Event) event.Event { |
| 2742 | + e.Tags[1][1] = grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{9}}) |
| 2743 | + return e |
| 2744 | + }, |
| 2745 | + } { |
| 2746 | + t.Run(name, func(t *testing.T) { |
| 2747 | + e := grantRequestEvent(t, testAgentSecret, grant, now+1, "please", reqJSON) |
| 2748 | + e = mutate(e) |
| 2749 | + if err := event.Sign(&e, testAgentSecret); err != nil { |
| 2750 | + t.Fatal(err) |
| 2751 | + } |
| 2752 | + if err := publishAs(t, tenant, e); err == nil { |
| 2753 | + t.Fatalf("malformed request accepted: %v", e) |
| 2754 | + } |
| 2755 | + }) |
| 2756 | + } |
| 2757 | +} |
| 2758 | + |
| 2759 | +func TestGrantRequestAdmissionRejectsPausedAndRevokedAgents(t *testing.T) { |
| 2760 | + for _, action := range []string{"pauseagent", "revokeagent"} { |
| 2761 | + t.Run(action, func(t *testing.T) { |
| 2762 | + _, tenant := testTenant(t) |
| 2763 | + now := time.Now().Unix() |
| 2764 | + agent, _ := event.PublicKey(testAgentSecret) |
| 2765 | + grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}) |
| 2766 | + if err := publishAs(t, tenant, grant); err != nil { |
| 2767 | + t.Fatal(err) |
| 2768 | + } |
| 2769 | + args := []json.RawMessage{json.RawMessage(`"` + agent + `"`)} |
| 2770 | + if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, action, args); err != nil { |
| 2771 | + t.Fatal(err) |
| 2772 | + } |
| 2773 | + req := grantRequestEvent(t, testAgentSecret, grant, now+1, "please", grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}})) |
| 2774 | + if err := publishAs(t, tenant, req); err == nil { |
| 2775 | + t.Fatalf("request accepted after %s", action) |
| 2776 | + } |
| 2777 | + }) |
| 2778 | + } |
| 2779 | +} |
| 2780 | + |
| 2781 | +func TestGrantRequestReplacementMustMatchPreparedReview(t *testing.T) { |
| 2782 | + mutations := map[string]func(*event.Event){ |
| 2783 | + "expiry": func(e *event.Event) { setGrantTag(t, e, "expiration", "9999999999") }, |
| 2784 | + "name": func(e *event.Event) { setGrantTag(t, e, "name", "changed") }, |
| 2785 | + "content": func(e *event.Event) { e.Content = "changed" }, |
| 2786 | + "tags": func(e *event.Event) { e.Tags = append(e.Tags, []string{"note", "changed"}) }, |
| 2787 | + } |
| 2788 | + for name, mutate := range mutations { |
| 2789 | + t.Run(name, func(t *testing.T) { |
| 2790 | + _, tenant := testTenant(t) |
| 2791 | + now := time.Now().Unix() |
| 2792 | + agent, _ := event.PublicKey(testAgentSecret) |
| 2793 | + grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}) |
| 2794 | + if err := publishAs(t, tenant, grant); err != nil { |
| 2795 | + t.Fatal(err) |
| 2796 | + } |
| 2797 | + req := grantRequestEvent(t, testAgentSecret, grant, now, "please", grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}})) |
| 2798 | + if err := publishAs(t, tenant, req); err != nil { |
| 2799 | + t.Fatal(err) |
| 2800 | + } |
| 2801 | + review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), req, now) |
| 2802 | + if err != nil { |
| 2803 | + t.Fatal(err) |
| 2804 | + } |
| 2805 | + replacement := review.Unsigned |
| 2806 | + mutate(&replacement) |
| 2807 | + if err := event.Sign(&replacement, testOwnerSecret); err != nil { |
| 2808 | + t.Fatal(err) |
| 2809 | + } |
| 2810 | + if err := publishAs(t, tenant, replacement); err == nil { |
| 2811 | + t.Fatalf("mutated %s replacement accepted", name) |
| 2812 | + } |
| 2813 | + }) |
| 2814 | + } |
| 2815 | +} |
| 2816 | + |
| 2817 | +func TestGrantRequestPreparedReplacementGoesStale(t *testing.T) { |
| 2818 | + for _, scenario := range []string{"pause", "revoke", "deny", "expire"} { |
| 2819 | + t.Run(scenario, func(t *testing.T) { |
| 2820 | + _, tenant := testTenant(t) |
| 2821 | + now := time.Now().Unix() |
| 2822 | + agent, _ := event.PublicKey(testAgentSecret) |
| 2823 | + grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}) |
| 2824 | + if err := publishAs(t, tenant, grant); err != nil { |
| 2825 | + t.Fatal(err) |
| 2826 | + } |
| 2827 | + tags := grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}}) |
| 2828 | + requestTags := [][]string(nil) |
| 2829 | + if scenario == "expire" { |
| 2830 | + requestTags = [][]string{{"expiration", strconv.FormatInt(now+1, 10)}} |
| 2831 | + } |
| 2832 | + request := grantRequestEvent(t, testAgentSecret, grant, now, "please", tags, requestTags...) |
| 2833 | + if err := publishAs(t, tenant, request); err != nil { |
| 2834 | + t.Fatal(err) |
| 2835 | + } |
| 2836 | + review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), request, now) |
| 2837 | + if err != nil { |
| 2838 | + t.Fatal(err) |
| 2839 | + } |
| 2840 | + replacement := review.Unsigned |
| 2841 | + if scenario == "deny" { |
| 2842 | + if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 7, now+1, [][]string{{"e", request.ID}, {"p", agent}}, "-")); err != nil { |
| 2843 | + t.Fatal(err) |
| 2844 | + } |
| 2845 | + } else if scenario != "expire" { |
| 2846 | + action := "pauseagent" |
| 2847 | + if scenario == "revoke" { |
| 2848 | + action = "revokeagent" |
| 2849 | + } |
| 2850 | + if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, action, []json.RawMessage{json.RawMessage(`"` + agent + `"`)}); err != nil { |
| 2851 | + t.Fatal(err) |
| 2852 | + } |
| 2853 | + } |
| 2854 | + if err := event.Sign(&replacement, testOwnerSecret); err != nil { |
| 2855 | + t.Fatal(err) |
| 2856 | + } |
| 2857 | + if scenario == "expire" { |
| 2858 | + time.Sleep(2 * time.Second) |
| 2859 | + } |
| 2860 | + if err := publishAs(t, tenant, replacement); err == nil { |
| 2861 | + t.Fatalf("stale %s replacement accepted", scenario) |
| 2862 | + } |
| 2863 | + }) |
| 2864 | + } |
| 2865 | +} |
| 2866 | diff --git a/internal/daemon/grant_approvals.go b/internal/daemon/grant_approvals.go |
| 2867 | new file mode 100644 |
| 2868 | index 0000000..1c1f6f2 |
| 2869 | --- /dev/null |
| 2870 | +++ b/internal/daemon/grant_approvals.go |
| 2871 | @@ -0,0 +1,94 @@ |
| 2872 | +package daemon |
| 2873 | + |
| 2874 | +import ( |
| 2875 | + "context" |
| 2876 | + "encoding/json" |
| 2877 | + "strings" |
| 2878 | + |
| 2879 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2880 | +) |
| 2881 | + |
| 2882 | +// Keep the decision visible after the addressable grant has been superseded |
| 2883 | +// or expired. Only archived grants tied to these exact requests are read. |
| 2884 | +func (t *Tenant) grantApprovalHistory(ctx context.Context, items []approvalItem) ([]event.Event, error) { |
| 2885 | + ids, authors := []string{}, []string{} |
| 2886 | + for _, item := range items { |
| 2887 | + if item.Type == "grant" { |
| 2888 | + ids = append(ids, item.ID) |
| 2889 | + authors = append(authors, item.Asked...) |
| 2890 | + } |
| 2891 | + } |
| 2892 | + if len(ids) == 0 { |
| 2893 | + return nil, nil |
| 2894 | + } |
| 2895 | + idJSON, err := json.Marshal(ids) |
| 2896 | + if err != nil { |
| 2897 | + return nil, err |
| 2898 | + } |
| 2899 | + authorJSON, err := json.Marshal(uniqueStrings(authors)) |
| 2900 | + if err != nil { |
| 2901 | + return nil, err |
| 2902 | + } |
| 2903 | + rows, err := t.store.DB().QueryContext(ctx, `SELECT raw FROM ( |
| 2904 | + SELECT raw,created_at FROM events WHERE kind=30392 AND pubkey IN (SELECT value FROM json_each(?)) |
| 2905 | + UNION ALL SELECT raw,created_at FROM agent_grant_revisions WHERE author IN (SELECT value FROM json_each(?)) |
| 2906 | + ) WHERE EXISTS (SELECT 1 FROM json_each(json_extract(raw,'$.tags')) AS tag |
| 2907 | + WHERE json_extract(tag.value,'$[0]')='grant-request' AND json_extract(tag.value,'$[1]') IN (SELECT value FROM json_each(?))) |
| 2908 | + ORDER BY created_at DESC LIMIT ?`, string(authorJSON), string(authorJSON), string(idJSON), approvalScan) |
| 2909 | + if err != nil { |
| 2910 | + return nil, err |
| 2911 | + } |
| 2912 | + defer rows.Close() |
| 2913 | + var grants []event.Event |
| 2914 | + for rows.Next() { |
| 2915 | + var raw string |
| 2916 | + if err := rows.Scan(&raw); err != nil { |
| 2917 | + return nil, err |
| 2918 | + } |
| 2919 | + grant, err := event.Parse([]byte(raw)) |
| 2920 | + if err != nil { |
| 2921 | + return nil, err |
| 2922 | + } |
| 2923 | + grants = append(grants, grant) |
| 2924 | + } |
| 2925 | + return grants, rows.Err() |
| 2926 | +} |
| 2927 | + |
| 2928 | +// A grant is approved only by the operator's replacement grant. Reactions |
| 2929 | +// cannot confer permissions or make an unapplied request look successful. |
| 2930 | +func settleGrantApproval(item *approvalItem, rows []event.Event, now int64) { |
| 2931 | + for _, row := range rows { |
| 2932 | + 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 { |
| 2933 | + continue |
| 2934 | + } |
| 2935 | + item.State = "answered" |
| 2936 | + item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: "approved", CreatedAt: row.CreatedAt} |
| 2937 | + return |
| 2938 | + } |
| 2939 | + for _, row := range rows { |
| 2940 | + if row.Kind != kindReaction || strings.TrimSpace(row.Content) != "-" || !containsString(item.Asked, row.PubKey) { |
| 2941 | + continue |
| 2942 | + } |
| 2943 | + item.State = "answered" |
| 2944 | + item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: "denied", CreatedAt: row.CreatedAt} |
| 2945 | + return |
| 2946 | + } |
| 2947 | + if item.Expires > 0 && item.Expires <= now { |
| 2948 | + item.State = "expired" |
| 2949 | + } |
| 2950 | +} |
| 2951 | + |
| 2952 | +func (t *Tenant) reviewGrantApproval(ctx context.Context, item *approvalItem, now int64) { |
| 2953 | + if item.Type != "grant" || item.State != "open" { |
| 2954 | + return |
| 2955 | + } |
| 2956 | + review, err := t.community.ReviewAgentGrantRequest(ctx, item.Event, now) |
| 2957 | + if err != nil { |
| 2958 | + item.GrantError = err.Error() |
| 2959 | + if strings.HasPrefix(item.GrantError, "conflict:") { |
| 2960 | + item.State = "expired" |
| 2961 | + } |
| 2962 | + return |
| 2963 | + } |
| 2964 | + item.Grant = &review |
| 2965 | +} |
| 2966 | diff --git a/internal/daemon/grant_approvals_test.go b/internal/daemon/grant_approvals_test.go |
| 2967 | new file mode 100644 |
| 2968 | index 0000000..113d2ae |
| 2969 | --- /dev/null |
| 2970 | +++ b/internal/daemon/grant_approvals_test.go |
| 2971 | @@ -0,0 +1,157 @@ |
| 2972 | +package daemon |
| 2973 | + |
| 2974 | +import ( |
| 2975 | + "context" |
| 2976 | + "encoding/json" |
| 2977 | + "strings" |
| 2978 | + "testing" |
| 2979 | + "time" |
| 2980 | + |
| 2981 | + "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 2982 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 2983 | +) |
| 2984 | + |
| 2985 | +func approvalGrantRequest(t *testing.T, grant event.Event, createdAt int64) event.Event { |
| 2986 | + t.Helper() |
| 2987 | + 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"}}}}) |
| 2988 | + if err != nil { |
| 2989 | + t.Fatal(err) |
| 2990 | + } |
| 2991 | + 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.") |
| 2992 | +} |
| 2993 | + |
| 2994 | +func TestGrantApprovalAppliesAndRetainsDecision(t *testing.T) { |
| 2995 | + _, tenant := testTenant(t) |
| 2996 | + ctx := context.Background() |
| 2997 | + now := time.Now().Unix() |
| 2998 | + agent, _ := event.PublicKey(testAgentSecret) |
| 2999 | + grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}, []string{"room", tenant.meta.Name}, []string{"sites", "*"}) |
| 3000 | + if err := publishAs(t, tenant, grant); err != nil { |
| 3001 | + t.Fatal(err) |
| 3002 | + } |
| 3003 | + request := approvalGrantRequest(t, grant, now-20) |
| 3004 | + if err := publishAs(t, tenant, request); err != nil { |
| 3005 | + t.Fatalf("request with no comment scope: %v", err) |
| 3006 | + } |
| 3007 | + detail, err := approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID}) |
| 3008 | + if err != nil { |
| 3009 | + t.Fatal(err) |
| 3010 | + } |
| 3011 | + item := detail["item"].(map[string]any) |
| 3012 | + if item["type"] != "grant" || item["state"] != "open" || item["grant"] == nil { |
| 3013 | + t.Fatalf("missing actionable review: %v", item) |
| 3014 | + } |
| 3015 | + for _, actor := range []string{agent, grant.PubKey} { |
| 3016 | + if _, err := approvalCall(t, tenant, actor, "browseapproval", map[string]any{"id": request.ID}); err != nil { |
| 3017 | + t.Fatalf("participant cannot read review: %v", err) |
| 3018 | + } |
| 3019 | + } |
| 3020 | + if _, err := approvalCall(t, tenant, wikiKey(t, approvalOtherSecret), "browseapproval", map[string]any{"id": request.ID}); err == nil { |
| 3021 | + t.Fatal("stranger read grant request") |
| 3022 | + } |
| 3023 | + // A reaction alone must never make the UI claim access was granted. |
| 3024 | + positive := signedEvent(t, testOwnerSecret, 7, now-10, [][]string{{"e", request.ID}, {"p", agent}}, "+") |
| 3025 | + if err := publishAs(t, tenant, positive); err != nil { |
| 3026 | + t.Fatal(err) |
| 3027 | + } |
| 3028 | + detail, err = approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID}) |
| 3029 | + if err != nil || detail["item"].(map[string]any)["state"] != "open" { |
| 3030 | + t.Fatalf("reaction settled an unapplied grant: %v %v", detail, err) |
| 3031 | + } |
| 3032 | + review, err := tenant.community.ReviewAgentGrantRequest(ctx, request, now) |
| 3033 | + if err != nil { |
| 3034 | + t.Fatal(err) |
| 3035 | + } |
| 3036 | + replacement := review.Unsigned |
| 3037 | + if err := event.Sign(&replacement, testOwnerSecret); err != nil { |
| 3038 | + t.Fatal(err) |
| 3039 | + } |
| 3040 | + if err := publishAs(t, tenant, replacement); err != nil { |
| 3041 | + t.Fatalf("reviewed replacement rejected: %v", err) |
| 3042 | + } |
| 3043 | + current, found, err := tenant.community.AgentGrant(ctx, agent) |
| 3044 | + if err != nil || !found || !containsInt(current.Scope.Kinds, 30617) || len(current.Scope.Sites) != 1 { |
| 3045 | + t.Fatalf("grant not applied or existing site lost: %+v %v", current, err) |
| 3046 | + } |
| 3047 | + // A later ordinary edit must not erase the approval's receipt. |
| 3048 | + manual := agentGrantEvent(t, agent, now+1, now+3600, []string{"k", "9"}) |
| 3049 | + if err := publishAs(t, tenant, manual); err != nil { |
| 3050 | + t.Fatal(err) |
| 3051 | + } |
| 3052 | + for _, method := range []string{"browseapproval", "browseapprovals"} { |
| 3053 | + result, err := approvalCall(t, tenant, grant.PubKey, method, map[string]any{"id": request.ID}) |
| 3054 | + if err != nil { |
| 3055 | + t.Fatal(err) |
| 3056 | + } |
| 3057 | + got := result["item"] |
| 3058 | + if method == "browseapprovals" { |
| 3059 | + got = approvalByID(wikiItems(result, "items"), request.ID) |
| 3060 | + } |
| 3061 | + item := got.(map[string]any) |
| 3062 | + answer := item["answer"].(map[string]any) |
| 3063 | + if item["state"] != "answered" || answer["decision"] != "approved" || answer["id"] != replacement.ID { |
| 3064 | + t.Fatalf("lost grant approval history: %v", item) |
| 3065 | + } |
| 3066 | + } |
| 3067 | +} |
| 3068 | + |
| 3069 | +func TestGrantApprovalDenialAndStaleReview(t *testing.T) { |
| 3070 | + for _, scenario := range []string{"deny", "supersede"} { |
| 3071 | + t.Run(scenario, func(t *testing.T) { |
| 3072 | + _, tenant := testTenant(t) |
| 3073 | + now := time.Now().Unix() |
| 3074 | + agent, _ := event.PublicKey(testAgentSecret) |
| 3075 | + grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}) |
| 3076 | + if err := publishAs(t, tenant, grant); err != nil { |
| 3077 | + t.Fatal(err) |
| 3078 | + } |
| 3079 | + request := approvalGrantRequest(t, grant, now-20) |
| 3080 | + if err := publishAs(t, tenant, request); err != nil { |
| 3081 | + t.Fatal(err) |
| 3082 | + } |
| 3083 | + review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), request, now) |
| 3084 | + if err != nil { |
| 3085 | + t.Fatal(err) |
| 3086 | + } |
| 3087 | + if scenario == "deny" { |
| 3088 | + if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 7, now-10, [][]string{{"e", request.ID}, {"p", agent}}, "-")); err != nil { |
| 3089 | + t.Fatal(err) |
| 3090 | + } |
| 3091 | + } else if err := publishAs(t, tenant, agentGrantEvent(t, agent, now-5, now+3600, []string{"k", "1"})); err != nil { |
| 3092 | + t.Fatal(err) |
| 3093 | + } |
| 3094 | + replacement := review.Unsigned |
| 3095 | + if err := event.Sign(&replacement, testOwnerSecret); err != nil { |
| 3096 | + t.Fatal(err) |
| 3097 | + } |
| 3098 | + if err := publishAs(t, tenant, replacement); err == nil { |
| 3099 | + t.Fatal("obsolete request changed the grant") |
| 3100 | + } |
| 3101 | + detail, err := approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID}) |
| 3102 | + if err != nil { |
| 3103 | + t.Fatal(err) |
| 3104 | + } |
| 3105 | + item := detail["item"].(map[string]any) |
| 3106 | + if item["grant"] != nil { |
| 3107 | + t.Fatalf("inactive request still actionable: %v", item) |
| 3108 | + } |
| 3109 | + if scenario == "deny" && (item["state"] != "answered" || item["answer"].(map[string]any)["decision"] != "denied") { |
| 3110 | + t.Fatalf("denial not shown: %v", item) |
| 3111 | + } |
| 3112 | + if scenario == "supersede" && (item["state"] != "expired" || !strings.HasPrefix(item["grant_error"].(string), "conflict:")) { |
| 3113 | + t.Fatalf("stale review not marked: %v", item) |
| 3114 | + } |
| 3115 | + }) |
| 3116 | + } |
| 3117 | +} |
| 3118 | + |
| 3119 | +func TestGrantRequestNotificationRequiresReview(t *testing.T) { |
| 3120 | + _, tenant := testTenant(t) |
| 3121 | + now := time.Now().Unix() |
| 3122 | + agent, _ := event.PublicKey(testAgentSecret) |
| 3123 | + request := approvalGrantRequest(t, agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}), now-20) |
| 3124 | + notices := tenant.pushNotices(context.Background(), request) |
| 3125 | + 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) { |
| 3126 | + t.Fatalf("grant request notification: %+v", notices) |
| 3127 | + } |
| 3128 | +} |
| 3129 | diff --git a/internal/daemon/http.go b/internal/daemon/http.go |
| 3130 | index 386d0a4..51b24ad 100644 |
| 3131 | --- a/internal/daemon/http.go |
| 3132 | +++ b/internal/daemon/http.go |
| 3133 | @@ -72,6 +72,10 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 3134 | t.pushHTTP(w, r) |
| 3135 | return |
| 3136 | } |
| 3137 | + if r.URL.Path == "/chat/preferences" { |
| 3138 | + t.chatPreferencesHTTP(w, r) |
| 3139 | + return |
| 3140 | + } |
| 3141 | if r.URL.Path == "/mcp" { |
| 3142 | t.mcpHTTP(w, r) |
| 3143 | return |
| 3144 | diff --git a/internal/daemon/jobs_test.go b/internal/daemon/jobs_test.go |
| 3145 | index acf60ef..b64b85b 100644 |
| 3146 | --- a/internal/daemon/jobs_test.go |
| 3147 | +++ b/internal/daemon/jobs_test.go |
| 3148 | @@ -11,6 +11,7 @@ import ( |
| 3149 | "time" |
| 3150 | |
| 3151 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 3152 | + "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 3153 | ) |
| 3154 | |
| 3155 | // jobTenant returns a tenant with a member who requests jobs and an agent |
| 3156 | @@ -169,6 +170,43 @@ func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) { |
| 3157 | } |
| 3158 | } |
| 3159 | |
| 3160 | +func TestMCPJobBuildersKeepRoomScope(t *testing.T) { |
| 3161 | + room := "private-work" |
| 3162 | + request, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{ |
| 3163 | + "kind": float64(5001), "room": room, |
| 3164 | + "inputs": []any{map[string]any{"data": "hello", "type": "text"}}, |
| 3165 | + }}) |
| 3166 | + if err != nil { |
| 3167 | + t.Fatalf("request builder: %v", err) |
| 3168 | + } |
| 3169 | + if got := event.Tag(event.Event{Tags: request.Tags}, "h"); got != room { |
| 3170 | + t.Fatalf("request room = %q, want %q", got, room) |
| 3171 | + } |
| 3172 | + |
| 3173 | + feedback, err := mcpBuildJobFeedback(mcp.Call{Arguments: map[string]any{ |
| 3174 | + "e": strings.Repeat("a", 64), "p": strings.Repeat("b", 64), |
| 3175 | + "room": room, "status": "processing", |
| 3176 | + }}) |
| 3177 | + if err != nil { |
| 3178 | + t.Fatalf("feedback builder: %v", err) |
| 3179 | + } |
| 3180 | + if got := event.Tag(event.Event{Tags: feedback.Tags}, "h"); got != room { |
| 3181 | + t.Fatalf("feedback room = %q, want %q", got, room) |
| 3182 | + } |
| 3183 | + |
| 3184 | + requestEvent := event.Event{ID: strings.Repeat("c", 64), PubKey: strings.Repeat("d", 64), Kind: 5001, Tags: request.Tags} |
| 3185 | + result, err := mcpBuildJobResult(mcp.Call{Arguments: map[string]any{ |
| 3186 | + "request": map[string]any{"id": requestEvent.ID, "pubkey": requestEvent.PubKey, "kind": float64(requestEvent.Kind), "created_at": float64(1), "tags": requestEvent.Tags, "content": ""}, |
| 3187 | + "content": "done", |
| 3188 | + }}) |
| 3189 | + if err != nil { |
| 3190 | + t.Fatalf("result builder: %v", err) |
| 3191 | + } |
| 3192 | + if got := event.Tag(event.Event{Tags: result.Tags}, "h"); got != room { |
| 3193 | + t.Fatalf("result room = %q, want %q", got, room) |
| 3194 | + } |
| 3195 | +} |
| 3196 | + |
| 3197 | func TestJobAnswersWakeTheRequesterAsMentions(t *testing.T) { |
| 3198 | _, tenant, member, agent := jobTenant(t) |
| 3199 | ctx := context.Background() |
| 3200 | diff --git a/internal/daemon/mcp_grant_request.go b/internal/daemon/mcp_grant_request.go |
| 3201 | new file mode 100644 |
| 3202 | index 0000000..866e9fd |
| 3203 | --- /dev/null |
| 3204 | +++ b/internal/daemon/mcp_grant_request.go |
| 3205 | @@ -0,0 +1,103 @@ |
| 3206 | +package daemon |
| 3207 | + |
| 3208 | +import ( |
| 3209 | + "context" |
| 3210 | + "encoding/json" |
| 3211 | + "errors" |
| 3212 | + "strings" |
| 3213 | + "time" |
| 3214 | + "unicode/utf8" |
| 3215 | + |
| 3216 | + "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 3217 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 3218 | + "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 3219 | +) |
| 3220 | + |
| 3221 | +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.` |
| 3222 | + |
| 3223 | +func mcpGrantRequestSchema() map[string]any { |
| 3224 | + text := map[string]any{"type": "string", "minLength": 1} |
| 3225 | + repo := mcp.Object(map[string]any{ |
| 3226 | + "owner": mcpPubKey, "identifier": text, |
| 3227 | + "level": map[string]any{"type": "string", "enum": []string{community.AgentRepoPropose, community.AgentRepoRead, community.AgentRepoMaintain}}, |
| 3228 | + }, "owner", "identifier", "level") |
| 3229 | + 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") |
| 3230 | + changes := mcp.Object(map[string]any{ |
| 3231 | + "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}}, |
| 3232 | + "rooms": map[string]any{"type": "array", "items": text}, "repos": map[string]any{"type": "array", "items": repo}, |
| 3233 | + "sites": map[string]any{"type": "array", "items": site}, "wiki": text, "jobs": text, |
| 3234 | + "rate": map[string]any{"type": "integer", "minimum": 1, "maximum": community.AgentRateMax}, |
| 3235 | + }) |
| 3236 | + return mcp.Object(map[string]any{"event": mcpEvent, "reason": map[string]any{"type": "string", "minLength": 1, "maxLength": 500}, "changes": changes}) |
| 3237 | +} |
| 3238 | + |
| 3239 | +func (t *Tenant) mcpGrantRequest(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 3240 | + if raw, present := call.Arguments["event"]; present && raw != nil { |
| 3241 | + encoded, err := json.Marshal(raw) |
| 3242 | + if err != nil { |
| 3243 | + return mcp.Result{}, err |
| 3244 | + } |
| 3245 | + e, err := event.Parse(encoded) |
| 3246 | + if err == nil && e.PubKey != call.Actor { |
| 3247 | + err = errors.New("the event pubkey must match the authenticated agent") |
| 3248 | + } |
| 3249 | + if err == nil { |
| 3250 | + _, err = t.community.ReviewAgentGrantRequest(ctx, e, time.Now().Unix()) |
| 3251 | + } |
| 3252 | + if err != nil { |
| 3253 | + return mcp.Failure("The grant request is not acceptable: "+err.Error()+"\n"+mcpGrantRequestShape, map[string]any{"expected": mcpGrantRequestShape}), nil |
| 3254 | + } |
| 3255 | + return t.mcpPublish(ctx, call, e), nil |
| 3256 | + } |
| 3257 | + unsigned, err := t.mcpBuildGrantRequest(ctx, call) |
| 3258 | + if err != nil { |
| 3259 | + return mcp.Failure(err.Error()+"\n"+mcpGrantRequestShape, map[string]any{"expected": mcpGrantRequestShape}), nil |
| 3260 | + } |
| 3261 | + return mcp.Value(map[string]any{"unsigned": unsigned, "next": mcpSignNext}), nil |
| 3262 | +} |
| 3263 | + |
| 3264 | +func (t *Tenant) mcpBuildGrantRequest(ctx context.Context, call mcp.Call) (mcpUnsigned, error) { |
| 3265 | + reason := strings.TrimSpace(call.String("reason")) |
| 3266 | + if reason == "" || !utf8.ValidString(reason) || len([]rune(reason)) > 500 { |
| 3267 | + return mcpUnsigned{}, errors.New("reason must be 1 to 500 valid UTF-8 characters") |
| 3268 | + } |
| 3269 | + grant, ok, err := t.community.AgentGrant(ctx, call.Actor) |
| 3270 | + if err != nil { |
| 3271 | + return mcpUnsigned{}, err |
| 3272 | + } |
| 3273 | + if !ok || !grant.Active(time.Now().Unix()) { |
| 3274 | + return mcpUnsigned{}, errors.New("only an active granted agent can request more permissions") |
| 3275 | + } |
| 3276 | + changes, err := mcpGrantChanges(call.Arguments["changes"]) |
| 3277 | + if err != nil { |
| 3278 | + return mcpUnsigned{}, err |
| 3279 | + } |
| 3280 | + grantJSON, err := json.Marshal(community.AgentGrantRequest{Base: grant.EventID, Changes: changes}) |
| 3281 | + if err != nil { |
| 3282 | + return mcpUnsigned{}, err |
| 3283 | + } |
| 3284 | + 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}} |
| 3285 | + unsigned := mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: reason} |
| 3286 | + request := event.Event{PubKey: call.Actor, Kind: unsigned.Kind, CreatedAt: unsigned.CreatedAt, Tags: unsigned.Tags, Content: unsigned.Content} |
| 3287 | + if _, err := t.community.ReviewAgentGrantRequest(ctx, request, time.Now().Unix()); err != nil { |
| 3288 | + return mcpUnsigned{}, err |
| 3289 | + } |
| 3290 | + return unsigned, nil |
| 3291 | +} |
| 3292 | + |
| 3293 | +func mcpGrantChanges(raw any) (community.AgentGrantChanges, error) { |
| 3294 | + encoded, err := json.Marshal(raw) |
| 3295 | + if err != nil { |
| 3296 | + return community.AgentGrantChanges{}, errors.New("changes must be an object") |
| 3297 | + } |
| 3298 | + var changes community.AgentGrantChanges |
| 3299 | + decoder := json.NewDecoder(strings.NewReader(string(encoded))) |
| 3300 | + decoder.DisallowUnknownFields() |
| 3301 | + if err := decoder.Decode(&changes); err != nil { |
| 3302 | + return community.AgentGrantChanges{}, errors.New("changes must contain only supported grant fields") |
| 3303 | + } |
| 3304 | + if changes.Kinds == nil && changes.Rooms == nil && changes.Repos == nil && changes.Sites == nil && changes.Wiki == nil && changes.Jobs == nil && changes.Rate == nil { |
| 3305 | + return community.AgentGrantChanges{}, errors.New("changes must request at least one permission") |
| 3306 | + } |
| 3307 | + return changes, nil |
| 3308 | +} |
| 3309 | diff --git a/internal/daemon/mcp_grant_request_test.go b/internal/daemon/mcp_grant_request_test.go |
| 3310 | new file mode 100644 |
| 3311 | index 0000000..c7bf8ce |
| 3312 | --- /dev/null |
| 3313 | +++ b/internal/daemon/mcp_grant_request_test.go |
| 3314 | @@ -0,0 +1,74 @@ |
| 3315 | +package daemon |
| 3316 | + |
| 3317 | +import ( |
| 3318 | + "context" |
| 3319 | + "strings" |
| 3320 | + "testing" |
| 3321 | + "time" |
| 3322 | + |
| 3323 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 3324 | + "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 3325 | +) |
| 3326 | + |
| 3327 | +func TestMCPGrantRequestBuildsOperatorBoundRequest(t *testing.T) { |
| 3328 | + _, tenant := testTenant(t) |
| 3329 | + agent, _ := event.PublicKey(testAgentSecret) |
| 3330 | + now := time.Now().Unix() |
| 3331 | + if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1111"})); err != nil { |
| 3332 | + t.Fatal(err) |
| 3333 | + } |
| 3334 | + changes := map[string]any{"kinds": []any{9}, "rooms": []any{"work"}} |
| 3335 | + unsigned, err := tenant.mcpBuildGrantRequest(context.Background(), mcp.Call{Actor: agent, Arguments: map[string]any{"reason": "Need chat replies", "changes": changes}}) |
| 3336 | + if err != nil { |
| 3337 | + t.Fatal(err) |
| 3338 | + } |
| 3339 | + if unsigned.Kind != 1111 || unsigned.Content != "Need chat replies" { |
| 3340 | + t.Fatalf("unsigned request: %+v", unsigned) |
| 3341 | + } |
| 3342 | + if event.Tag(event.Event{Tags: unsigned.Tags}, "p") != tenant.Policy().Owner || event.Tag(event.Event{Tags: unsigned.Tags}, "request") != "grant" { |
| 3343 | + t.Fatalf("request is not bound to operator: %v", unsigned.Tags) |
| 3344 | + } |
| 3345 | + grant := event.Tag(event.Event{Tags: unsigned.Tags}, "grant") |
| 3346 | + if !strings.Contains(grant, `"base"`) || !strings.Contains(grant, `"kinds":[9]`) { |
| 3347 | + t.Fatalf("grant change payload: %s", grant) |
| 3348 | + } |
| 3349 | +} |
| 3350 | + |
| 3351 | +func TestMCPGrantRequestRejectsUnsupportedChanges(t *testing.T) { |
| 3352 | + for _, raw := range []any{map[string]any{"unknown": "x"}, map[string]any{}} { |
| 3353 | + if _, err := mcpGrantChanges(raw); err == nil { |
| 3354 | + t.Errorf("accepted invalid changes: %#v", raw) |
| 3355 | + } |
| 3356 | + } |
| 3357 | +} |
| 3358 | + |
| 3359 | +func TestMCPGrantRequestRejectsMissingGrantAndLongReason(t *testing.T) { |
| 3360 | + _, tenant := testTenant(t) |
| 3361 | + agent, _ := event.PublicKey(testAgentSecret) |
| 3362 | + call := mcp.Call{Actor: agent, Arguments: map[string]any{"reason": "need access", "changes": map[string]any{"kinds": []any{float64(9)}}}} |
| 3363 | + if _, err := tenant.mcpBuildGrantRequest(context.Background(), call); err == nil { |
| 3364 | + t.Fatal("agent without a grant accepted") |
| 3365 | + } |
| 3366 | + now := time.Now().Unix() |
| 3367 | + if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1111"})); err != nil { |
| 3368 | + t.Fatal(err) |
| 3369 | + } |
| 3370 | + call.Arguments["reason"] = strings.Repeat("x", 501) |
| 3371 | + if _, err := tenant.mcpBuildGrantRequest(context.Background(), call); err == nil { |
| 3372 | + t.Fatal("501-character reason accepted") |
| 3373 | + } |
| 3374 | +} |
| 3375 | + |
| 3376 | +func TestMCPGrantRequestSchema(t *testing.T) { |
| 3377 | + schema := mcpGrantRequestSchema() |
| 3378 | + if err := mcp.Validate(schema, map[string]any{"event": map[string]any{}}); err != nil { |
| 3379 | + t.Fatalf("signed follow-up should be schema-valid: %v", err) |
| 3380 | + } |
| 3381 | + valid := map[string]any{"reason": "Need room chat", "changes": map[string]any{"kinds": []any{float64(9)}}} |
| 3382 | + if err := mcp.Validate(schema, valid); err != nil { |
| 3383 | + t.Fatal(err) |
| 3384 | + } |
| 3385 | + if err := mcp.Validate(schema, map[string]any{"reason": "", "changes": map[string]any{"kinds": []any{float64(9)}}}); err == nil { |
| 3386 | + t.Fatal("empty reason accepted") |
| 3387 | + } |
| 3388 | +} |
| 3389 | diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go |
| 3390 | index f960af1..0399882 100644 |
| 3391 | --- a/internal/daemon/mcp_http.go |
| 3392 | +++ b/internal/daemon/mcp_http.go |
| 3393 | @@ -20,7 +20,7 @@ func (t *Tenant) initMCP() error { |
| 3394 | t.mcp = &mcp.Server{ |
| 3395 | Tools: tools, |
| 3396 | Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version}, |
| 3397 | - 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.", |
| 3398 | + 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.", |
| 3399 | Authenticate: t.mcpAuthenticate, |
| 3400 | Challenge: mcpChallenge, |
| 3401 | Origin: t.mcpOrigin, |
| 3402 | @@ -93,7 +93,7 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) { |
| 3403 | "- Methods: server/discover, tools/list and tools/call.", |
| 3404 | "- 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.", |
| 3405 | "- 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.", |
| 3406 | - "- 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.", |
| 3407 | + "- 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.", |
| 3408 | "", |
| 3409 | "## Files", |
| 3410 | "", |
| 3411 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 3412 | index 5e99f94..d4161cb 100644 |
| 3413 | --- a/internal/daemon/mcp_tools.go |
| 3414 | +++ b/internal/daemon/mcp_tools.go |
| 3415 | @@ -71,9 +71,9 @@ const ( |
| 3416 | 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.` |
| 3417 | mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].` |
| 3418 | 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.` |
| 3419 | - 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.` |
| 3420 | - 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.` |
| 3421 | - 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.` |
| 3422 | + 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.` |
| 3423 | + 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.` |
| 3424 | + 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.` |
| 3425 | 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.` |
| 3426 | mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument." |
| 3427 | 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." |
| 3428 | @@ -230,9 +230,10 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) { |
| 3429 | 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)) |
| 3430 | 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)) |
| 3431 | 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)) |
| 3432 | - 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)) |
| 3433 | - 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)) |
| 3434 | - 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)) |
| 3435 | + 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)) |
| 3436 | + 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)) |
| 3437 | + 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)) |
| 3438 | + 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) |
| 3439 | return registry, err |
| 3440 | } |
| 3441 | |
| 3442 | @@ -917,6 +918,12 @@ func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) { |
| 3443 | return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5127 or 5129 to 5999") |
| 3444 | } |
| 3445 | tags := [][]string{} |
| 3446 | + if room := strings.TrimSpace(call.String("room")); room != "" { |
| 3447 | + if !community.ValidRoomID(room) { |
| 3448 | + return mcpUnsigned{}, errors.New("room must be a room id") |
| 3449 | + } |
| 3450 | + tags = append(tags, []string{"h", room}) |
| 3451 | + } |
| 3452 | if inputs, ok := call.Arguments["inputs"].([]any); ok { |
| 3453 | for _, raw := range inputs { |
| 3454 | input, _ := raw.(map[string]any) |
| 3455 | @@ -1010,7 +1017,14 @@ func mcpBuildJobFeedback(call mcp.Call) (mcpUnsigned, error) { |
| 3456 | if info := strings.TrimSpace(call.String("info")); info != "" { |
| 3457 | statusTag = append(statusTag, info) |
| 3458 | } |
| 3459 | - tags, err := mcpJobAmount(call, [][]string{statusTag, {"e", request}, {"p", requester}}) |
| 3460 | + tags := [][]string{statusTag, {"e", request}, {"p", requester}} |
| 3461 | + if room := strings.TrimSpace(call.String("room")); room != "" { |
| 3462 | + if !community.ValidRoomID(room) { |
| 3463 | + return mcpUnsigned{}, errors.New("room must be a room id") |
| 3464 | + } |
| 3465 | + tags = append(tags, []string{"h", room}) |
| 3466 | + } |
| 3467 | + tags, err := mcpJobAmount(call, tags) |
| 3468 | if err != nil { |
| 3469 | return mcpUnsigned{}, err |
| 3470 | } |
| 3471 | @@ -1053,12 +1067,26 @@ func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) { |
| 3472 | } |
| 3473 | tags := [][]string{} |
| 3474 | if hasRequest { |
| 3475 | + if room := event.Tag(request, "h"); room != "" { |
| 3476 | + tags = append(tags, []string{"h", room}) |
| 3477 | + } |
| 3478 | canonical, err := event.Canonical(request) |
| 3479 | if err != nil { |
| 3480 | return mcpUnsigned{}, err |
| 3481 | } |
| 3482 | tags = append(tags, []string{"request", string(canonical)}) |
| 3483 | } |
| 3484 | + if room := strings.TrimSpace(call.String("room")); room != "" { |
| 3485 | + if !community.ValidRoomID(room) { |
| 3486 | + return mcpUnsigned{}, errors.New("room must be a room id") |
| 3487 | + } |
| 3488 | + if hasRequest && event.Tag(request, "h") != room { |
| 3489 | + return mcpUnsigned{}, errors.New("room must match the request") |
| 3490 | + } |
| 3491 | + if !hasRequest { |
| 3492 | + tags = append(tags, []string{"h", room}) |
| 3493 | + } |
| 3494 | + } |
| 3495 | tags = append(tags, []string{"e", id}) |
| 3496 | if hasRequest { |
| 3497 | for _, tag := range request.Tags { |
| 3498 | diff --git a/internal/daemon/room_threads.go b/internal/daemon/room_threads.go |
| 3499 | new file mode 100644 |
| 3500 | index 0000000..d0ba38b |
| 3501 | --- /dev/null |
| 3502 | +++ b/internal/daemon/room_threads.go |
| 3503 | @@ -0,0 +1,93 @@ |
| 3504 | +package daemon |
| 3505 | + |
| 3506 | +import ( |
| 3507 | + "context" |
| 3508 | + "errors" |
| 3509 | + |
| 3510 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 3511 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 3512 | +) |
| 3513 | + |
| 3514 | +func (t *Tenant) roomThreadRoot(ctx context.Context, actor, room, id string) (event.Event, error) { |
| 3515 | + seen := map[string]bool{} |
| 3516 | + for len(seen) < 64 && !seen[id] { |
| 3517 | + seen[id] = true |
| 3518 | + rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Limit: intPtr(1)}}, browseSession(t, actor)) |
| 3519 | + if err != nil { |
| 3520 | + return event.Event{}, err |
| 3521 | + } |
| 3522 | + if len(rows) != 1 || event.Tag(rows[0], "h") != room { |
| 3523 | + break |
| 3524 | + } |
| 3525 | + root := rows[0] |
| 3526 | + if root.Kind != 9 && root.Kind != 11 && root.Kind != 12 && root.Kind != 40002 { |
| 3527 | + break |
| 3528 | + } |
| 3529 | + parent := event.RoomReplyRoot(root) |
| 3530 | + if parent == "" { |
| 3531 | + return root, nil |
| 3532 | + } |
| 3533 | + id = parent |
| 3534 | + } |
| 3535 | + return event.Event{}, errors.New("not found: thread") |
| 3536 | +} |
| 3537 | + |
| 3538 | +// Follow indexed references so parent-only Buzz replies join the same thread |
| 3539 | +// as marked root/reply pairs. The traversal is bounded against hostile trees. |
| 3540 | +func (t *Tenant) roomThreadReplies(ctx context.Context, actor, room, root string) ([]event.Event, error) { |
| 3541 | + known := map[string]bool{root: true} |
| 3542 | + frontier := []string{root} |
| 3543 | + var replies []event.Event |
| 3544 | + for depth := 0; len(frontier) > 0 && depth < 64; depth++ { |
| 3545 | + var nextLevel []string |
| 3546 | + for start := 0; start < len(frontier); start += 100 { |
| 3547 | + end := min(start+100, len(frontier)) |
| 3548 | + filter := event.Filter{Kinds: []int{9, 12}, Tags: map[string][]string{"h": {room}, "e": frontier[start:end]}} |
| 3549 | + var cursor *storage.EventCursor |
| 3550 | + for { |
| 3551 | + rows, next, err := t.roomMessages(ctx, actor, filter, cursor, 100) |
| 3552 | + if err != nil { |
| 3553 | + return nil, err |
| 3554 | + } |
| 3555 | + for _, row := range rows { |
| 3556 | + if known[row.ID] || !known[event.RoomReplyRoot(row)] { |
| 3557 | + continue |
| 3558 | + } |
| 3559 | + known[row.ID] = true |
| 3560 | + replies = append(replies, row) |
| 3561 | + nextLevel = append(nextLevel, row.ID) |
| 3562 | + if len(replies) > 5000 { |
| 3563 | + return nil, errors.New("restricted: thread exceeds the browser limit") |
| 3564 | + } |
| 3565 | + } |
| 3566 | + if next == "" { |
| 3567 | + break |
| 3568 | + } |
| 3569 | + cursor, err = parseCollaborationCursor(next) |
| 3570 | + if err != nil { |
| 3571 | + return nil, err |
| 3572 | + } |
| 3573 | + } |
| 3574 | + } |
| 3575 | + frontier = nextLevel |
| 3576 | + } |
| 3577 | + if len(frontier) > 0 { |
| 3578 | + return nil, errors.New("restricted: thread exceeds the nesting limit") |
| 3579 | + } |
| 3580 | + sortEventsNewestFirst(replies) |
| 3581 | + return replies, nil |
| 3582 | +} |
| 3583 | + |
| 3584 | +func roomThreadPage(rows []event.Event, cursor *storage.EventCursor, limit int) ([]event.Event, string) { |
| 3585 | + page := make([]event.Event, 0, limit) |
| 3586 | + for _, row := range rows { |
| 3587 | + if cursor != nil && (row.CreatedAt > cursor.CreatedAt || row.CreatedAt == cursor.CreatedAt && row.ID <= cursor.ID) { |
| 3588 | + continue |
| 3589 | + } |
| 3590 | + if len(page) == limit { |
| 3591 | + return page, collaborationCursor(collaborationItemFrom(page[len(page)-1])) |
| 3592 | + } |
| 3593 | + page = append(page, row) |
| 3594 | + } |
| 3595 | + return page, "" |
| 3596 | +} |
| 3597 | diff --git a/internal/daemon/rooms_stream.go b/internal/daemon/rooms_stream.go |
| 3598 | index afe4e60..5d2531a 100644 |
| 3599 | --- a/internal/daemon/rooms_stream.go |
| 3600 | +++ b/internal/daemon/rooms_stream.go |
| 3601 | @@ -77,7 +77,14 @@ func (t *Tenant) roomStreamHTTP(w http.ResponseWriter, r *http.Request) { |
| 3602 | events := make(chan event.Event, roomStreamBuffer) |
| 3603 | overflow := make(chan struct{}, 1) |
| 3604 | stop := t.router.Listen(func(e event.Event) { |
| 3605 | - if e.Kind == event.KIND_MARMOT_GROUP || event.Tag(e, "h") != id { |
| 3606 | + globalPresence := e.Kind == 20001 && event.Tag(e, "h") == "" && actor != "" |
| 3607 | + if globalPresence { |
| 3608 | + allowed, _, err := t.community.RoomWriteAllowed(r.Context(), id, e.PubKey) |
| 3609 | + if err != nil || !allowed { |
| 3610 | + return |
| 3611 | + } |
| 3612 | + } |
| 3613 | + if e.Kind == event.KIND_MARMOT_GROUP || (event.Tag(e, "h") != id && !globalPresence) { |
| 3614 | return |
| 3615 | } |
| 3616 | select { |
| 3617 | diff --git a/internal/daemon/rooms_test.go b/internal/daemon/rooms_test.go |
| 3618 | index 6e05eee..db36a5c 100644 |
| 3619 | --- a/internal/daemon/rooms_test.go |
| 3620 | +++ b/internal/daemon/rooms_test.go |
| 3621 | @@ -254,6 +254,72 @@ func TestRoomsLifecycleRecordsAndBrowse(t *testing.T) { |
| 3622 | } |
| 3623 | } |
| 3624 | |
| 3625 | +func TestBuzzKindNineRepliesUseNIP10ThreadRoots(t *testing.T) { |
| 3626 | + h := newRoomHarness(t) |
| 3627 | + root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "Buzz root") |
| 3628 | + parent := h.must("bob", event.KIND_CHAT, [][]string{ |
| 3629 | + {"h", "main"}, {"e", root.ID, "", "reply"}, {"e", root.ID, "", "root"}, {"p", h.keys["alice"]}, |
| 3630 | + }, "Buzz reply") |
| 3631 | + nested := h.must("alice", event.KIND_CHAT, [][]string{ |
| 3632 | + {"h", "main"}, {"e", parent.ID, "", "reply"}, {"p", h.keys["bob"]}, |
| 3633 | + }, "Nested reply") |
| 3634 | + thread, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 10}) |
| 3635 | + if err != nil { |
| 3636 | + t.Fatal(err) |
| 3637 | + } |
| 3638 | + replies := thread["replies"].([]any) |
| 3639 | + if len(replies) != 2 { |
| 3640 | + t.Fatalf("Buzz thread replies = %d, want 2: %v", len(replies), replies) |
| 3641 | + } |
| 3642 | + seen := map[string]bool{} |
| 3643 | + for _, row := range replies { |
| 3644 | + seen[row.(map[string]any)["id"].(string)] = true |
| 3645 | + } |
| 3646 | + if !seen[parent.ID] || !seen[nested.ID] { |
| 3647 | + t.Fatalf("Buzz thread omitted replies: %v", seen) |
| 3648 | + } |
| 3649 | + room, err := h.browse("bob", "browseroom", map[string]any{"id": "main", "limit": 100}) |
| 3650 | + if err != nil { |
| 3651 | + t.Fatal(err) |
| 3652 | + } |
| 3653 | + roomSeen := map[string]int{} |
| 3654 | + for _, row := range room["messages"].([]any) { |
| 3655 | + roomSeen[row.(map[string]any)["id"].(string)]++ |
| 3656 | + } |
| 3657 | + for _, id := range []string{root.ID, parent.ID, nested.ID} { |
| 3658 | + if roomSeen[id] != 1 { |
| 3659 | + t.Fatalf("room timeline count for %s = %d, want 1", id, roomSeen[id]) |
| 3660 | + } |
| 3661 | + } |
| 3662 | +} |
| 3663 | + |
| 3664 | +func TestRoomThreadRejectsCrossRoomRootsAndPaginatesNestedReplies(t *testing.T) { |
| 3665 | + h := newRoomHarness(t) |
| 3666 | + root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root") |
| 3667 | + first := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, "first") |
| 3668 | + second := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", first.ID, "", "reply"}}, "second") |
| 3669 | + page, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 1}) |
| 3670 | + if err != nil { |
| 3671 | + t.Fatal(err) |
| 3672 | + } |
| 3673 | + if len(page["replies"].([]any)) != 1 || page["next_cursor"] == "" { |
| 3674 | + t.Fatalf("first thread page = %v", page) |
| 3675 | + } |
| 3676 | + more, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 1, "cursor": page["next_cursor"]}) |
| 3677 | + if err != nil || len(more["replies"].([]any)) != 1 { |
| 3678 | + t.Fatalf("second thread page = %v (%v)", more, err) |
| 3679 | + } |
| 3680 | + if page["replies"].([]any)[0].(map[string]any)["id"] == more["replies"].([]any)[0].(map[string]any)["id"] { |
| 3681 | + t.Fatal("thread cursor repeated a reply") |
| 3682 | + } |
| 3683 | + other := h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "other"}, {"visibility", "open"}}, "") |
| 3684 | + _ = other |
| 3685 | + if _, err := h.browse("bob", "browsethread", map[string]any{"id": "other", "event": root.ID}); err == nil { |
| 3686 | + t.Fatal("cross-room root was accepted") |
| 3687 | + } |
| 3688 | + _ = second |
| 3689 | +} |
| 3690 | + |
| 3691 | func TestTypedRoomReaderPreservesSignedEvents(t *testing.T) { |
| 3692 | h := newRoomHarness(t) |
| 3693 | 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") |
| 3694 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 3695 | index 261bcf7..0f05137 100644 |
| 3696 | --- a/internal/daemon/services.go |
| 3697 | +++ b/internal/daemon/services.go |
| 3698 | @@ -284,6 +284,10 @@ func (b backend) ReadAllowed(ctx context.Context, actor string) error { |
| 3699 | return b.tenant.requirePrivateAccess(ctx, actor) |
| 3700 | } |
| 3701 | |
| 3702 | +func (b backend) SharePresence(ctx context.Context, actor string) bool { |
| 3703 | + return b.tenant.chatSharePresence(ctx, actor) |
| 3704 | +} |
| 3705 | + |
| 3706 | // closeServices cancels and joins the tenant's worker, scheduler and Git |
| 3707 | // loops, then returns the worker's terminal queue error, if any. Store and |
| 3708 | // relay connection cleanup belong to Tenant.Close. |
| 3709 | diff --git a/internal/daemon/session_test.go b/internal/daemon/session_test.go |
| 3710 | index 0b1d9d7..f357967 100644 |
| 3711 | --- a/internal/daemon/session_test.go |
| 3712 | +++ b/internal/daemon/session_test.go |
| 3713 | @@ -103,6 +103,47 @@ func TestSignedSessionSurvivesNavigationAndRevokes(t *testing.T) { |
| 3714 | } |
| 3715 | } |
| 3716 | |
| 3717 | +func TestChatPreferencesArePrivateAndDurablePerAccount(t *testing.T) { |
| 3718 | + a, tenant := testTenant(t) |
| 3719 | + login := httptest.NewRequest(http.MethodPost, "http://relay.test/session", strings.NewReader("")) |
| 3720 | + signRequest(t, login, "") |
| 3721 | + loginResult := httptest.NewRecorder() |
| 3722 | + a.ServeHTTP(loginResult, login) |
| 3723 | + if loginResult.Code != http.StatusOK { |
| 3724 | + t.Fatalf("login: %d %s", loginResult.Code, loginResult.Body.String()) |
| 3725 | + } |
| 3726 | + cookie := loginResult.Result().Cookies()[0] |
| 3727 | + read := httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil) |
| 3728 | + read.AddCookie(cookie) |
| 3729 | + result := httptest.NewRecorder() |
| 3730 | + a.ServeHTTP(result, read) |
| 3731 | + if result.Code != http.StatusOK || result.Body.String() != `{"share_presence":false}` { |
| 3732 | + t.Fatalf("default preference: %d %s", result.Code, result.Body.String()) |
| 3733 | + } |
| 3734 | + update := httptest.NewRequest(http.MethodPost, "http://relay.test/chat/preferences", strings.NewReader(`{"share_presence":true}`)) |
| 3735 | + signRequest(t, update, `{"share_presence":true}`) |
| 3736 | + updated := httptest.NewRecorder() |
| 3737 | + a.ServeHTTP(updated, update) |
| 3738 | + if updated.Code != http.StatusOK || updated.Body.String() != `{"share_presence":true}` { |
| 3739 | + t.Fatalf("updated preference: %d %s", updated.Code, updated.Body.String()) |
| 3740 | + } |
| 3741 | + if !tenant.chatSharePresence(context.Background(), tenant.Policy().Owner) { |
| 3742 | + t.Fatal("preference was not persisted") |
| 3743 | + } |
| 3744 | + reloaded := httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil) |
| 3745 | + reloaded.AddCookie(cookie) |
| 3746 | + reloadedResult := httptest.NewRecorder() |
| 3747 | + a.ServeHTTP(reloadedResult, reloaded) |
| 3748 | + if reloadedResult.Code != http.StatusOK || reloadedResult.Body.String() != `{"share_presence":true}` { |
| 3749 | + t.Fatalf("reloaded preference: %d %s", reloadedResult.Code, reloadedResult.Body.String()) |
| 3750 | + } |
| 3751 | + guest := httptest.NewRecorder() |
| 3752 | + a.ServeHTTP(guest, httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil)) |
| 3753 | + if guest.Code != http.StatusUnauthorized { |
| 3754 | + t.Fatalf("guest preference: %d", guest.Code) |
| 3755 | + } |
| 3756 | +} |
| 3757 | + |
| 3758 | func TestTenantMaintenanceUsesPersistedRetention(t *testing.T) { |
| 3759 | _, tenant := testTenant(t) |
| 3760 | ctx := context.Background() |
| 3761 | diff --git a/internal/daemon/webui_rooms.go b/internal/daemon/webui_rooms.go |
| 3762 | index 202a08c..8d4199f 100644 |
| 3763 | --- a/internal/daemon/webui_rooms.go |
| 3764 | +++ b/internal/daemon/webui_rooms.go |
| 3765 | @@ -119,7 +119,7 @@ func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, curs |
| 3766 | if err := t.browseRead(ctx, actor); err != nil { |
| 3767 | return webui.RoomPage{}, err |
| 3768 | } |
| 3769 | - room, _, err := t.roomFor(ctx, actor, roomID) |
| 3770 | + room, role, err := t.roomFor(ctx, actor, roomID) |
| 3771 | if err != nil { |
| 3772 | return webui.RoomPage{}, err |
| 3773 | } |
| 3774 | @@ -130,23 +130,25 @@ func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, curs |
| 3775 | if err != nil { |
| 3776 | return webui.RoomPage{}, errors.New("invalid: thread cursor") |
| 3777 | } |
| 3778 | - roots, err := t.Query(ctx, []event.Filter{{IDs: []string{rootID}, Limit: intPtr(1)}}, browseSession(t, actor)) |
| 3779 | + root, err := t.roomThreadRoot(ctx, actor, room.ID, rootID) |
| 3780 | if err != nil { |
| 3781 | return webui.RoomPage{}, err |
| 3782 | } |
| 3783 | - 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) { |
| 3784 | - return webui.RoomPage{}, errors.New("not found: thread") |
| 3785 | - } |
| 3786 | - 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)) |
| 3787 | + all, err := t.roomThreadReplies(ctx, actor, room.ID, root.ID) |
| 3788 | if err != nil { |
| 3789 | return webui.RoomPage{}, err |
| 3790 | } |
| 3791 | - targets := append([]event.Event{roots[0]}, replies...) |
| 3792 | + replies, next := roomThreadPage(all, position, normalizeRoomLimit(limit)) |
| 3793 | + targets := append([]event.Event{root}, replies...) |
| 3794 | edits, err := t.roomEdits(ctx, actor, room.ID, targets) |
| 3795 | if err != nil { |
| 3796 | return webui.RoomPage{}, err |
| 3797 | } |
| 3798 | - return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Root: &roots[0], Replies: replies, Edits: edits, NextCursor: next}, nil |
| 3799 | + members, err := t.webuiRoomMembers(ctx, room.ID, role) |
| 3800 | + if err != nil { |
| 3801 | + return webui.RoomPage{}, err |
| 3802 | + } |
| 3803 | + return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, Edits: edits, NextCursor: next}, nil |
| 3804 | } |
| 3805 | |
| 3806 | func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]webui.RoomMember, error) { |
| 3807 | @@ -163,7 +165,17 @@ func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]w |
| 3808 | if err != nil { |
| 3809 | return nil, err |
| 3810 | } |
| 3811 | - members = append(members, webui.RoomMember{PubKey: row.PubKey, Role: row.Role, AddedAt: row.AddedAt, Agent: tenantRole == "agent"}) |
| 3812 | + member := webui.RoomMember{PubKey: row.PubKey, Role: row.Role, AddedAt: row.AddedAt, Agent: tenantRole == "agent"} |
| 3813 | + if member.Agent { |
| 3814 | + grant, found, grantErr := t.community.AgentGrant(ctx, row.PubKey) |
| 3815 | + if grantErr != nil { |
| 3816 | + return nil, grantErr |
| 3817 | + } |
| 3818 | + if found { |
| 3819 | + member.Operator = grant.Owner |
| 3820 | + } |
| 3821 | + } |
| 3822 | + members = append(members, member) |
| 3823 | } |
| 3824 | return members, nil |
| 3825 | } |
| 3826 | diff --git a/internal/event/room_reply.go b/internal/event/room_reply.go |
| 3827 | new file mode 100644 |
| 3828 | index 0000000..2224726 |
| 3829 | --- /dev/null |
| 3830 | +++ b/internal/event/room_reply.go |
| 3831 | @@ -0,0 +1,48 @@ |
| 3832 | +package event |
| 3833 | + |
| 3834 | +import "encoding/hex" |
| 3835 | + |
| 3836 | +// RoomReplyRoot reads NIP-10 marked references and the older unmarked |
| 3837 | +// NIP-29 thread convention. A mention or q tag never makes a reply. |
| 3838 | +func RoomReplyRoot(e Event) string { |
| 3839 | + if e.Kind != KIND_CHAT && e.Kind != KIND_THREAD_REPLY { |
| 3840 | + return "" |
| 3841 | + } |
| 3842 | + var root, reply, first string |
| 3843 | + marked := false |
| 3844 | + for _, tag := range e.Tags { |
| 3845 | + if len(tag) < 2 || tag[0] != "e" || len(tag[1]) != 64 { |
| 3846 | + continue |
| 3847 | + } |
| 3848 | + if _, err := hex.DecodeString(tag[1]); err != nil { |
| 3849 | + continue |
| 3850 | + } |
| 3851 | + marker := "" |
| 3852 | + if len(tag) > 3 { |
| 3853 | + marker = tag[3] |
| 3854 | + } |
| 3855 | + if marker != "" { |
| 3856 | + marked = true |
| 3857 | + } |
| 3858 | + switch marker { |
| 3859 | + case "root": |
| 3860 | + root = tag[1] |
| 3861 | + case "reply": |
| 3862 | + reply = tag[1] |
| 3863 | + case "": |
| 3864 | + if first == "" { |
| 3865 | + first = tag[1] |
| 3866 | + } |
| 3867 | + } |
| 3868 | + } |
| 3869 | + if root != "" { |
| 3870 | + return root |
| 3871 | + } |
| 3872 | + if reply != "" { |
| 3873 | + return reply |
| 3874 | + } |
| 3875 | + if !marked { |
| 3876 | + return first |
| 3877 | + } |
| 3878 | + return "" |
| 3879 | +} |
| 3880 | diff --git a/internal/event/room_reply_test.go b/internal/event/room_reply_test.go |
| 3881 | new file mode 100644 |
| 3882 | index 0000000..120bec8 |
| 3883 | --- /dev/null |
| 3884 | +++ b/internal/event/room_reply_test.go |
| 3885 | @@ -0,0 +1,30 @@ |
| 3886 | +package event |
| 3887 | + |
| 3888 | +import ( |
| 3889 | + "strings" |
| 3890 | + "testing" |
| 3891 | +) |
| 3892 | + |
| 3893 | +func TestRoomReplyRoot(t *testing.T) { |
| 3894 | + a, b := strings.Repeat("a", 64), strings.Repeat("b", 64) |
| 3895 | + for _, test := range []struct { |
| 3896 | + name string |
| 3897 | + kind int |
| 3898 | + tags [][]string |
| 3899 | + want string |
| 3900 | + }{ |
| 3901 | + {"root and parent", 9, [][]string{{"e", b, "", "reply", b}, {"e", a, "", "root", a}}, a}, |
| 3902 | + {"parent only", 9, [][]string{{"e", b, "", "reply"}}, b}, |
| 3903 | + {"mention is not reply", 9, [][]string{{"e", a, "", "mention"}}, ""}, |
| 3904 | + {"quote is not reply", 9, [][]string{{"q", a}}, ""}, |
| 3905 | + {"legacy", 12, [][]string{{"e", a}}, a}, |
| 3906 | + {"unmarked", 9, [][]string{{"e", a}, {"e", b}}, a}, |
| 3907 | + {"reaction", 7, [][]string{{"e", a}}, ""}, |
| 3908 | + } { |
| 3909 | + t.Run(test.name, func(t *testing.T) { |
| 3910 | + if got := RoomReplyRoot(Event{Kind: test.kind, Tags: test.tags}); got != test.want { |
| 3911 | + t.Fatalf("got %q want %q", got, test.want) |
| 3912 | + } |
| 3913 | + }) |
| 3914 | + } |
| 3915 | +} |
| 3916 | diff --git a/internal/gates/gates.go b/internal/gates/gates.go |
| 3917 | index 416d234..62d481c 100644 |
| 3918 | --- a/internal/gates/gates.go |
| 3919 | +++ b/internal/gates/gates.go |
| 3920 | @@ -71,6 +71,9 @@ func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) err |
| 3921 | return fmt.Errorf("check agent grant: %w", err) |
| 3922 | } |
| 3923 | if !ok { |
| 3924 | + if community.IsAgentGrantRequest(e) { |
| 3925 | + return errors.New("restricted: grant requests require an active agent grant") |
| 3926 | + } |
| 3927 | return nil |
| 3928 | } |
| 3929 | role, err := g.cfg.Community.Role(ctx, e.PubKey) |
| 3930 | @@ -78,10 +81,20 @@ func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) err |
| 3931 | return fmt.Errorf("check agent role: %w", err) |
| 3932 | } |
| 3933 | if role != "" && role != "agent" { |
| 3934 | + if community.IsAgentGrantRequest(e) { |
| 3935 | + return errors.New("restricted: only the granted agent may request a grant change") |
| 3936 | + } |
| 3937 | return nil |
| 3938 | } |
| 3939 | - if err := grant.Check(e, now); err != nil { |
| 3940 | - return err |
| 3941 | + if community.IsAgentGrantRequest(e) { |
| 3942 | + if _, reviewErr := g.cfg.Community.ReviewAgentGrantRequest(ctx, e, now); reviewErr != nil { |
| 3943 | + return reviewErr |
| 3944 | + } |
| 3945 | + } |
| 3946 | + if !community.IsAgentGrantRequest(e) { |
| 3947 | + if err := grant.Check(e, now); err != nil { |
| 3948 | + return err |
| 3949 | + } |
| 3950 | } |
| 3951 | if err := g.jobReply(ctx, e, now, true); err != nil { |
| 3952 | return err |
| 3953 | @@ -107,6 +120,11 @@ func (g *Gate) agentGrantShape(ctx context.Context, e event.Event, now int64) er |
| 3954 | if !community.CanGrantAgents(role) { |
| 3955 | return errors.New("restricted: only the owner or a moderator can grant an agent") |
| 3956 | } |
| 3957 | + if event.Tag(e, "grant-request") != "" || event.Tag(e, "grant-base") != "" { |
| 3958 | + if err := g.cfg.Community.ValidateAgentGrantReplacement(ctx, e, now); err != nil { |
| 3959 | + return err |
| 3960 | + } |
| 3961 | + } |
| 3962 | _, err = community.ParseAgentGrant(e, now) |
| 3963 | return err |
| 3964 | } |
| 3965 | @@ -674,6 +692,12 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session |
| 3966 | // ban, repository privacy, policy, room and recipient checks. |
| 3967 | func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *event.Filter) bool { |
| 3968 | p := g.cfg.Policy() |
| 3969 | + if community.IsAgentGrantRequest(e) { |
| 3970 | + operator := event.Tag(e, "p") |
| 3971 | + if !contains(s.PubKeys, e.PubKey) && !contains(s.PubKeys, operator) { |
| 3972 | + return false |
| 3973 | + } |
| 3974 | + } |
| 3975 | if g.cfg.Store != nil { |
| 3976 | var held int |
| 3977 | 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 { |
| 3978 | diff --git a/internal/gates/jobs.go b/internal/gates/jobs.go |
| 3979 | index 690833a..4220519 100644 |
| 3980 | --- a/internal/gates/jobs.go |
| 3981 | +++ b/internal/gates/jobs.go |
| 3982 | @@ -122,6 +122,9 @@ func (g *Gate) jobReply(ctx context.Context, e event.Event, now int64, agent boo |
| 3983 | if event.IsJobResult(e.Kind) && e.Kind != event.JobResultKind(request.Kind) { |
| 3984 | return fmt.Errorf("invalid: job result kind %d does not answer a kind %d request", e.Kind, request.Kind) |
| 3985 | } |
| 3986 | + if room := event.Tag(request, "h"); room != "" && event.Tag(e, "h") != room { |
| 3987 | + return errors.New("invalid: job result or feedback must keep the request's room h tag") |
| 3988 | + } |
| 3989 | if event.Tag(e, "p") != request.PubKey { |
| 3990 | return errors.New("invalid: job result or feedback p tag must name the requester") |
| 3991 | } |
| 3992 | diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go |
| 3993 | index 36963cc..a6ff46a 100644 |
| 3994 | --- a/internal/mcp/tools.go |
| 3995 | +++ b/internal/mcp/tools.go |
| 3996 | @@ -11,6 +11,7 @@ import ( |
| 3997 | "sort" |
| 3998 | "strconv" |
| 3999 | "strings" |
| 4000 | + "unicode/utf8" |
| 4001 | ) |
| 4002 | |
| 4003 | // Tool is one entry in a server's tool table. |
| 4004 | @@ -186,10 +187,10 @@ func validate(schema map[string]any, value any, path string) error { |
| 4005 | } |
| 4006 | } |
| 4007 | if text, ok := value.(string); ok { |
| 4008 | - if min, ok := number(schema["minLength"]); ok && float64(len(text)) < min { |
| 4009 | + if min, ok := number(schema["minLength"]); ok && float64(utf8.RuneCountInString(text)) < min { |
| 4010 | return fmt.Errorf("%s must not be empty", where()) |
| 4011 | } |
| 4012 | - if max, ok := number(schema["maxLength"]); ok && float64(len(text)) > max { |
| 4013 | + if max, ok := number(schema["maxLength"]); ok && float64(utf8.RuneCountInString(text)) > max { |
| 4014 | return fmt.Errorf("%s is longer than %d characters", where(), int(max)) |
| 4015 | } |
| 4016 | } |
| 4017 | diff --git a/internal/mcp/unicode_schema_test.go b/internal/mcp/unicode_schema_test.go |
| 4018 | new file mode 100644 |
| 4019 | index 0000000..fbd80d4 |
| 4020 | --- /dev/null |
| 4021 | +++ b/internal/mcp/unicode_schema_test.go |
| 4022 | @@ -0,0 +1,16 @@ |
| 4023 | +package mcp |
| 4024 | + |
| 4025 | +import ( |
| 4026 | + "strings" |
| 4027 | + "testing" |
| 4028 | +) |
| 4029 | + |
| 4030 | +func TestSchemaStringLengthCountsUnicodeCharacters(t *testing.T) { |
| 4031 | + schema := map[string]any{"type": "string", "minLength": 1, "maxLength": 500} |
| 4032 | + if err := Validate(schema, strings.Repeat("猫", 500)); err != nil { |
| 4033 | + t.Fatal(err) |
| 4034 | + } |
| 4035 | + if err := Validate(schema, strings.Repeat("猫", 501)); err == nil { |
| 4036 | + t.Fatal("501 characters accepted") |
| 4037 | + } |
| 4038 | +} |
| 4039 | diff --git a/internal/storage/maintenance.go b/internal/storage/maintenance.go |
| 4040 | index 53742e4..21aff58 100644 |
| 4041 | --- a/internal/storage/maintenance.go |
| 4042 | +++ b/internal/storage/maintenance.go |
| 4043 | @@ -54,6 +54,7 @@ func (s *Store) Vanish(ctx context.Context, pubkey string, until int64) error { |
| 4044 | {"DELETE FROM events WHERE kind=1059 AND id IN(SELECT event_id FROM tags WHERE name='p' AND value=?)", []any{pubkey}}, |
| 4045 | {"DELETE FROM list_history WHERE owner=?", []any{pubkey}}, |
| 4046 | {"DELETE FROM wiki_revisions WHERE author=?", []any{pubkey}}, |
| 4047 | + {"DELETE FROM agent_grant_revisions WHERE author=?", []any{pubkey}}, |
| 4048 | } |
| 4049 | for _, q := range statements { |
| 4050 | if _, err := tx.ExecContext(ctx, q.sql, q.args...); err != nil { |
| 4051 | @@ -104,7 +105,10 @@ func (s *Store) EraseAuthor(ctx context.Context, pubkey string) (int64, error) { |
| 4052 | if _, err = tx.ExecContext(ctx, "DELETE FROM list_history WHERE owner=?", pubkey); err != nil { |
| 4053 | return err |
| 4054 | } |
| 4055 | - _, err = tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=?", pubkey) |
| 4056 | + if _, err = tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=?", pubkey); err != nil { |
| 4057 | + return err |
| 4058 | + } |
| 4059 | + _, err = tx.ExecContext(ctx, "DELETE FROM agent_grant_revisions WHERE author=?", pubkey) |
| 4060 | return err |
| 4061 | }) |
| 4062 | return n, err |
| 4063 | diff --git a/internal/storage/save.go b/internal/storage/save.go |
| 4064 | index 89cb0ea..8ceda1c 100644 |
| 4065 | --- a/internal/storage/save.go |
| 4066 | +++ b/internal/storage/save.go |
| 4067 | @@ -182,6 +182,11 @@ func replace(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error { |
| 4068 | return fmt.Errorf("archive replaced wiki revision: %w", err) |
| 4069 | } |
| 4070 | } |
| 4071 | + if e.Kind == event.KIND_AGENT_GRANT { |
| 4072 | + 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 { |
| 4073 | + return fmt.Errorf("archive replaced agent grant: %w", err) |
| 4074 | + } |
| 4075 | + } |
| 4076 | _, err = tx.ExecContext(ctx, "DELETE FROM events WHERE pubkey=? AND kind=? AND d=?", e.PubKey, e.Kind, d) |
| 4077 | if err != nil { |
| 4078 | return fmt.Errorf("replace event: %w", err) |
| 4079 | @@ -199,6 +204,11 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string) |
| 4080 | if err != nil { |
| 4081 | return nil |
| 4082 | } |
| 4083 | + if kind == event.KIND_AGENT_GRANT { |
| 4084 | + if err := archiveAgentGrantDeletion(ctx, tx, e, parts[1], parts[2]); err != nil { |
| 4085 | + return err |
| 4086 | + } |
| 4087 | + } |
| 4088 | 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 { |
| 4089 | return fmt.Errorf("delete address: %w", err) |
| 4090 | } |
| 4091 | @@ -211,6 +221,9 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string) |
| 4092 | } |
| 4093 | } |
| 4094 | } else { |
| 4095 | + if err := archiveAgentGrantEventDeletion(ctx, tx, e, tag[1]); err != nil { |
| 4096 | + return err |
| 4097 | + } |
| 4098 | 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 { |
| 4099 | return fmt.Errorf("delete event: %w", err) |
| 4100 | } |
| 4101 | @@ -228,6 +241,22 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string) |
| 4102 | return nil |
| 4103 | } |
| 4104 | |
| 4105 | +func archiveAgentGrantDeletion(ctx context.Context, tx *sql.Tx, deletion event.Event, owner, d string) error { |
| 4106 | + _, 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) |
| 4107 | + if err != nil { |
| 4108 | + return fmt.Errorf("archive deleted agent grant: %w", err) |
| 4109 | + } |
| 4110 | + return nil |
| 4111 | +} |
| 4112 | + |
| 4113 | +func archiveAgentGrantEventDeletion(ctx context.Context, tx *sql.Tx, deletion event.Event, target string) error { |
| 4114 | + _, 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) |
| 4115 | + if err != nil { |
| 4116 | + return fmt.Errorf("archive deleted agent grant: %w", err) |
| 4117 | + } |
| 4118 | + return nil |
| 4119 | +} |
| 4120 | + |
| 4121 | func insertEvent(ctx context.Context, tx *sql.Tx, e event.Event, mode string) (int64, error) { |
| 4122 | raw, err := event.Canonical(e) |
| 4123 | if err != nil { |
| 4124 | diff --git a/internal/storage/schema.go b/internal/storage/schema.go |
| 4125 | index 293c9c4..9e030e4 100644 |
| 4126 | --- a/internal/storage/schema.go |
| 4127 | +++ b/internal/storage/schema.go |
| 4128 | @@ -28,6 +28,7 @@ CREATE INDEX IF NOT EXISTS marmot_principal_pk ON marmot_principals(pubkey); |
| 4129 | CREATE TABLE IF NOT EXISTS list_history(owner TEXT NOT NULL,kind INTEGER NOT NULL,d TEXT NOT NULL DEFAULT '',event_id TEXT NOT NULL,created_at INTEGER NOT NULL,saved_at INTEGER NOT NULL,expires INTEGER NOT NULL DEFAULT 0,raw TEXT NOT NULL,PRIMARY KEY(owner,kind,d,event_id)); |
| 4130 | CREATE INDEX IF NOT EXISTS list_history_owner ON list_history(owner,saved_at DESC); |
| 4131 | CREATE TABLE IF NOT EXISTS wiki_revisions(event_id TEXT PRIMARY KEY,author TEXT NOT NULL,d TEXT NOT NULL,created_at INTEGER NOT NULL,raw TEXT NOT NULL,superseded_by TEXT NOT NULL); |
| 4132 | +CREATE TABLE IF NOT EXISTS agent_grant_revisions(event_id TEXT PRIMARY KEY,author TEXT NOT NULL,d TEXT NOT NULL,created_at INTEGER NOT NULL,raw TEXT NOT NULL,superseded_by TEXT NOT NULL); |
| 4133 | CREATE INDEX IF NOT EXISTS wiki_revisions_page ON wiki_revisions(d,created_at DESC); |
| 4134 | CREATE INDEX IF NOT EXISTS wiki_revisions_author ON wiki_revisions(author,d,created_at DESC); |
| 4135 | CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT NOT NULL); |
| 4136 | diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go |
| 4137 | index 550dcec..02562c5 100644 |
| 4138 | --- a/internal/storage/store_test.go |
| 4139 | +++ b/internal/storage/store_test.go |
| 4140 | @@ -60,6 +60,31 @@ func TestDurableAcceptanceIncludesIntents(t *testing.T) { |
| 4141 | } |
| 4142 | } |
| 4143 | |
| 4144 | +func TestDeletionArchivesAgentGrant(t *testing.T) { |
| 4145 | + s := openTestStore(t) |
| 4146 | + ctx := context.Background() |
| 4147 | + agent := "0000000000000000000000000000000000000000000000000000000000000002" |
| 4148 | + grant := sampleEvent(t, event.KIND_AGENT_GRANT, 100, [][]string{{"d", agent}, {"p", agent}, {"expiration", "200"}}) |
| 4149 | + if _, err := s.Save(ctx, grant, SaveOptions{Now: 100}); err != nil { |
| 4150 | + t.Fatal(err) |
| 4151 | + } |
| 4152 | + del := sampleEvent(t, event.KIND_DELETION, 110, [][]string{{"e", grant.ID}}) |
| 4153 | + del.PubKey = grant.PubKey |
| 4154 | + if err := event.Sign(&del, "0000000000000000000000000000000000000000000000000000000000000001"); err != nil { |
| 4155 | + t.Fatal(err) |
| 4156 | + } |
| 4157 | + if _, err := s.Save(ctx, del, SaveOptions{Now: 110}); err != nil { |
| 4158 | + t.Fatal(err) |
| 4159 | + } |
| 4160 | + var raw string |
| 4161 | + if err := s.DB().QueryRowContext(ctx, "SELECT raw FROM agent_grant_revisions WHERE event_id=?", grant.ID).Scan(&raw); err != nil { |
| 4162 | + t.Fatal(err) |
| 4163 | + } |
| 4164 | + if raw == "" { |
| 4165 | + t.Fatal("deleted grant revision is empty") |
| 4166 | + } |
| 4167 | +} |
| 4168 | + |
| 4169 | func TestEventAndIntentRollbackTogether(t *testing.T) { |
| 4170 | s := openTestStore(t) |
| 4171 | e := sampleEvent(t, 1, 100, nil) |
| 4172 | diff --git a/internal/webui/approvals.go b/internal/webui/approvals.go |
| 4173 | index 18a2b7e..02baa76 100644 |
| 4174 | --- a/internal/webui/approvals.go |
| 4175 | +++ b/internal/webui/approvals.go |
| 4176 | @@ -3,6 +3,7 @@ package webui |
| 4177 | import ( |
| 4178 | "context" |
| 4179 | "encoding/json" |
| 4180 | + "strconv" |
| 4181 | "strings" |
| 4182 | ) |
| 4183 | |
| 4184 | @@ -23,8 +24,17 @@ type approvalView struct { |
| 4185 | Decision string |
| 4186 | AnsweredAt int64 |
| 4187 | AnswerContent string |
| 4188 | + Grant *grantRequestView |
| 4189 | } |
| 4190 | |
| 4191 | +type grantRequestView struct { |
| 4192 | + Raw string |
| 4193 | + Error string |
| 4194 | + Rows []grantScopeRow |
| 4195 | +} |
| 4196 | + |
| 4197 | +type grantScopeRow struct{ Label, Before, After string } |
| 4198 | + |
| 4199 | // approvalViews returns the items in the given states, in the order the |
| 4200 | // backend listed them. |
| 4201 | func approvalViews(value any, base string, states ...string) []approvalView { |
| 4202 | @@ -81,9 +91,91 @@ func approvalViewFrom(item map[string]any, base string) approvalView { |
| 4203 | if answer := valueMap(item["answer"]); len(answer) > 0 { |
| 4204 | view.Decision, view.AnsweredAt, view.AnswerContent = plainString(answer["decision"]), unixSeconds(answer["created_at"]), strings.TrimSpace(plainString(answer["content"])) |
| 4205 | } |
| 4206 | + if view.Type == "grant" { |
| 4207 | + raw := valueMap(item["grant"]) |
| 4208 | + rows := []grantScopeRow{} |
| 4209 | + if len(raw) > 0 { |
| 4210 | + rows = grantScopeRows(raw) |
| 4211 | + } else { |
| 4212 | + raw = map[string]any{"request_id": view.ID, "agent": view.Asker, "operator": joinJSONValues(item["asked"])} |
| 4213 | + } |
| 4214 | + encoded, _ := json.Marshal(raw) |
| 4215 | + view.Grant = &grantRequestView{Raw: string(encoded), Error: plainString(item["grant_error"]), Rows: rows} |
| 4216 | + } |
| 4217 | return view |
| 4218 | } |
| 4219 | |
| 4220 | +func grantScopeRows(review map[string]any) []grantScopeRow { |
| 4221 | + before := valueMap(review["before"]) |
| 4222 | + after := valueMap(review["after"]) |
| 4223 | + bs, as := valueMap(before["scope"]), valueMap(after["scope"]) |
| 4224 | + 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"])}} |
| 4225 | + for i := range rows { |
| 4226 | + if rows[i].Before == "" { |
| 4227 | + rows[i].Before = "none" |
| 4228 | + } |
| 4229 | + if rows[i].After == "" { |
| 4230 | + rows[i].After = "none" |
| 4231 | + } |
| 4232 | + } |
| 4233 | + return rows |
| 4234 | +} |
| 4235 | + |
| 4236 | +func joinJSONValues(value any) string { |
| 4237 | + // JSON numbers do not unmarshal into []string. Render both numeric kind |
| 4238 | + // lists and textual lists through their JSON representation so the review |
| 4239 | + // table never silently loses a proposed permission. |
| 4240 | + var values []any |
| 4241 | + if encoded, err := json.Marshal(value); err == nil { |
| 4242 | + _ = json.Unmarshal(encoded, &values) |
| 4243 | + } |
| 4244 | + parts := make([]string, 0, len(values)) |
| 4245 | + for _, item := range values { |
| 4246 | + switch typed := item.(type) { |
| 4247 | + case string: |
| 4248 | + parts = append(parts, typed) |
| 4249 | + case float64: |
| 4250 | + parts = append(parts, strconv.FormatInt(int64(typed), 10)) |
| 4251 | + default: |
| 4252 | + if encoded, err := json.Marshal(item); err == nil { |
| 4253 | + parts = append(parts, string(encoded)) |
| 4254 | + } |
| 4255 | + } |
| 4256 | + } |
| 4257 | + return strings.Join(parts, ", ") |
| 4258 | +} |
| 4259 | + |
| 4260 | +func joinGrantRepos(value any) string { |
| 4261 | + var repos []map[string]any |
| 4262 | + if encoded, err := json.Marshal(value); err == nil { |
| 4263 | + _ = json.Unmarshal(encoded, &repos) |
| 4264 | + } |
| 4265 | + parts := make([]string, 0, len(repos)) |
| 4266 | + for _, repo := range repos { |
| 4267 | + parts = append(parts, plainString(repo["owner"])+":"+plainString(repo["identifier"])+":"+plainString(repo["level"])) |
| 4268 | + } |
| 4269 | + return strings.Join(parts, ", ") |
| 4270 | +} |
| 4271 | + |
| 4272 | +func joinGrantSites(value any) string { |
| 4273 | + var sites []map[string]any |
| 4274 | + if encoded, err := json.Marshal(value); err == nil { |
| 4275 | + _ = json.Unmarshal(encoded, &sites) |
| 4276 | + } |
| 4277 | + parts := make([]string, 0, len(sites)) |
| 4278 | + for _, site := range sites { |
| 4279 | + part := plainString(site["label"]) |
| 4280 | + if ttl := plainString(site["ttl"]); ttl != "" && ttl != "0" { |
| 4281 | + part += " ttl=" + ttl |
| 4282 | + } |
| 4283 | + if site["encrypted"] == true { |
| 4284 | + part += " encrypted" |
| 4285 | + } |
| 4286 | + parts = append(parts, part) |
| 4287 | + } |
| 4288 | + return strings.Join(parts, ", ") |
| 4289 | +} |
| 4290 | + |
| 4291 | // includeApproval makes sure the request a notification opened is on the |
| 4292 | // page: when the listing does not carry it, the single request is read and |
| 4293 | // placed first. A request that cannot be read leaves the listing as it is. |
| 4294 | diff --git a/internal/webui/approvals_test.go b/internal/webui/approvals_test.go |
| 4295 | index 049c34f..a4fc524 100644 |
| 4296 | --- a/internal/webui/approvals_test.go |
| 4297 | +++ b/internal/webui/approvals_test.go |
| 4298 | @@ -105,6 +105,20 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) { |
| 4299 | } |
| 4300 | } |
| 4301 | |
| 4302 | +func TestGrantScopeRowsRenderNumericKinds(t *testing.T) { |
| 4303 | + review := map[string]any{ |
| 4304 | + "before": map[string]any{"agent": strings.Repeat("a", 64), "scope": map[string]any{"kinds": []any{9.0, 30617.0}}}, |
| 4305 | + "after": map[string]any{"agent": strings.Repeat("a", 64), "scope": map[string]any{"kinds": []any{9.0, 30617.0, 30618.0}}}, |
| 4306 | + } |
| 4307 | + rows := grantScopeRows(review) |
| 4308 | + if got := rows[7].Before; got != "9, 30617" { |
| 4309 | + t.Fatalf("before kinds = %q", got) |
| 4310 | + } |
| 4311 | + if got := rows[7].After; got != "9, 30617, 30618" { |
| 4312 | + t.Fatalf("after kinds = %q", got) |
| 4313 | + } |
| 4314 | +} |
| 4315 | + |
| 4316 | func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) { |
| 4317 | backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 4318 | app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }}) |
| 4319 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 4320 | index 71d3c77..a531382 100644 |
| 4321 | --- a/internal/webui/browse.html |
| 4322 | +++ b/internal/webui/browse.html |
| 4323 | @@ -55,13 +55,14 @@ |
| 4324 | |
| 4325 | {{define "approvals"}}<section> |
| 4326 | <h1>Approvals</h1> |
| 4327 | -<p>Requests that wait for your decision. Each answer is a signed reaction; a reply opens the thread.</p> |
| 4328 | +<p>Requests that wait for your decision. Review requests, answer questions and approve proposed changes.</p> |
| 4329 | {{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to see the requests addressed to your key.</p> |
| 4330 | {{else if .Error}}{{template "alert" .Error}} |
| 4331 | {{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}}"> |
| 4332 | <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> |
| 4333 | <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}} |
| 4334 | -<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> |
| 4335 | +{{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}} |
| 4336 | +<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> |
| 4337 | </approval-item> |
| 4338 | {{end}}{{else}}<p>Nothing waits for your decision.</p> |
| 4339 | {{end}}{{if $done}}<h3>Answered</h3> |
| 4340 | diff --git a/internal/webui/chat-activity.go b/internal/webui/chat-activity.go |
| 4341 | new file mode 100644 |
| 4342 | index 0000000..eb752de |
| 4343 | --- /dev/null |
| 4344 | +++ b/internal/webui/chat-activity.go |
| 4345 | @@ -0,0 +1,184 @@ |
| 4346 | +package webui |
| 4347 | + |
| 4348 | +import ( |
| 4349 | + "bytes" |
| 4350 | + "context" |
| 4351 | + "encoding/json" |
| 4352 | + "net/http" |
| 4353 | + "net/url" |
| 4354 | + "sort" |
| 4355 | + "strings" |
| 4356 | + "time" |
| 4357 | +) |
| 4358 | + |
| 4359 | +type ChatActivityReader interface { |
| 4360 | + ReadChatActivity(context.Context, string, string, string) (any, error) |
| 4361 | +} |
| 4362 | + |
| 4363 | +type chatActivityItem struct { |
| 4364 | + Type, ID, Author, Kind, State, Status, Title, Content, Room, Decision string |
| 4365 | + CreatedAt, Expires int64 |
| 4366 | + Answer string |
| 4367 | + CanDecide, Question, Encrypted bool |
| 4368 | + Result *chatActivityResult |
| 4369 | +} |
| 4370 | +type chatActivityResult struct{ ID, Content string } |
| 4371 | +type chatActivityPage struct { |
| 4372 | + Items []chatActivityItem |
| 4373 | + Endpoint, Room, Actor, Error string |
| 4374 | +} |
| 4375 | + |
| 4376 | +func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatActivityItem { |
| 4377 | + items := []chatActivityItem{} |
| 4378 | + for _, raw := range collaborationSlice(jobs, "items") { |
| 4379 | + item := valueMap(raw) |
| 4380 | + id := plainString(item["id"]) |
| 4381 | + if !eventIDPattern.MatchString(id) { |
| 4382 | + continue |
| 4383 | + } |
| 4384 | + ev := valueMap(item["event"]) |
| 4385 | + title := firstString(tagValues(ev, "subject")) |
| 4386 | + if title == "" { |
| 4387 | + title = strings.TrimSpace(plainString(item["content"])) |
| 4388 | + } |
| 4389 | + if title == "" { |
| 4390 | + for _, raw := range collaborationSlice(item, "inputs") { |
| 4391 | + input := valueMap(raw) |
| 4392 | + if plainString(input["type"]) == "text" { |
| 4393 | + title = plainString(input["data"]) |
| 4394 | + break |
| 4395 | + } |
| 4396 | + } |
| 4397 | + } |
| 4398 | + if title == "" { |
| 4399 | + title = "Agent task" |
| 4400 | + } |
| 4401 | + title = truncateActivity(title, 100) |
| 4402 | + 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"])} |
| 4403 | + row.Encrypted, _ = item["encrypted"].(bool) |
| 4404 | + if row.Encrypted { |
| 4405 | + row.Title = "Encrypted task" |
| 4406 | + row.Content = "Open the task in its originating client." |
| 4407 | + } |
| 4408 | + if feedback := valueMap(item["feedback"]); len(feedback) > 0 && !row.Encrypted { |
| 4409 | + row.Author = plainString(feedback["provider"]) |
| 4410 | + row.Content = strings.TrimSpace(plainString(feedback["info"]) + "\n" + plainString(feedback["content"])) |
| 4411 | + } |
| 4412 | + if result := valueMap(item["result"]); eventIDPattern.MatchString(plainString(result["id"])) { |
| 4413 | + row.Result = &chatActivityResult{ID: plainString(result["id"]), Content: plainString(result["content"])} |
| 4414 | + row.Status = "success" |
| 4415 | + if row.Encrypted { |
| 4416 | + row.Result.Content = "" |
| 4417 | + } |
| 4418 | + } |
| 4419 | + if row.Status == "" { |
| 4420 | + row.Status = "queued" |
| 4421 | + } |
| 4422 | + items = append(items, row) |
| 4423 | + } |
| 4424 | + for _, raw := range collaborationSlice(approvals, "items") { |
| 4425 | + item := valueMap(raw) |
| 4426 | + id := plainString(item["id"]) |
| 4427 | + if !eventIDPattern.MatchString(id) || actor == "" { |
| 4428 | + continue |
| 4429 | + } |
| 4430 | + asker := plainString(item["asker"]) |
| 4431 | + can := approvalActorMayDecide(item, actor) |
| 4432 | + if !can && asker != actor { |
| 4433 | + continue |
| 4434 | + } |
| 4435 | + title := strings.TrimSpace(plainString(item["subject"])) |
| 4436 | + if title == "" { |
| 4437 | + title = plainString(item["content"]) |
| 4438 | + } |
| 4439 | + state := plainString(item["state"]) |
| 4440 | + expires := unixSeconds(item["expires"]) |
| 4441 | + if state == "open" && expires > 0 && expires <= time.Now().Unix() { |
| 4442 | + state = "expired" |
| 4443 | + } |
| 4444 | + answer := valueMap(item["answer"]) |
| 4445 | + decision := plainString(answer["decision"]) |
| 4446 | + 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}) |
| 4447 | + } |
| 4448 | + sort.SliceStable(items, func(i, j int) bool { |
| 4449 | + if items[i].CreatedAt == items[j].CreatedAt { |
| 4450 | + return items[i].ID < items[j].ID |
| 4451 | + } |
| 4452 | + return items[i].CreatedAt < items[j].CreatedAt |
| 4453 | + }) |
| 4454 | + return items |
| 4455 | +} |
| 4456 | +func truncateActivity(value string, max int) string { |
| 4457 | + runes := []rune(value) |
| 4458 | + if len(runes) > max { |
| 4459 | + return string(runes[:max]) + "…" |
| 4460 | + } |
| 4461 | + return value |
| 4462 | +} |
| 4463 | +func approvalActorMayDecide(item map[string]any, actor string) bool { |
| 4464 | + if actor == "" || plainString(item["asker"]) == actor { |
| 4465 | + return false |
| 4466 | + } |
| 4467 | + for _, key := range stringValues(item["asked"]) { |
| 4468 | + if key == actor { |
| 4469 | + return true |
| 4470 | + } |
| 4471 | + } |
| 4472 | + return false |
| 4473 | +} |
| 4474 | +func stringValues(value any) []string { |
| 4475 | + raw, err := json.Marshal(value) |
| 4476 | + if err != nil { |
| 4477 | + return nil |
| 4478 | + } |
| 4479 | + var values []string |
| 4480 | + if json.Unmarshal(raw, &values) != nil { |
| 4481 | + return nil |
| 4482 | + } |
| 4483 | + return values |
| 4484 | +} |
| 4485 | + |
| 4486 | +func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage { |
| 4487 | + endpoint := "/chat/activity?room=" + url.QueryEscape(room) |
| 4488 | + if root != "" { |
| 4489 | + endpoint += "&root=" + url.QueryEscape(root) |
| 4490 | + } |
| 4491 | + page := chatActivityPage{Endpoint: endpoint, Room: room, Actor: actor} |
| 4492 | + reader, ok := a.backend.(ChatActivityReader) |
| 4493 | + if !ok { |
| 4494 | + return page |
| 4495 | + } |
| 4496 | + result, err := reader.ReadChatActivity(ctx, actor, room, root) |
| 4497 | + if err != nil { |
| 4498 | + page.Error = "Agent activity is unavailable." |
| 4499 | + return page |
| 4500 | + } |
| 4501 | + data := valueMap(result) |
| 4502 | + page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room) |
| 4503 | + return page |
| 4504 | +} |
| 4505 | +func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) { |
| 4506 | + w.Header().Set("Cache-Control", "private, no-store") |
| 4507 | + actor, err := a.resolveActor(r) |
| 4508 | + if err != nil { |
| 4509 | + http.Error(w, "Sign in to see chat activity.", http.StatusUnauthorized) |
| 4510 | + return |
| 4511 | + } |
| 4512 | + room, root := r.URL.Query().Get("room"), r.URL.Query().Get("root") |
| 4513 | + if !roomIDPattern.MatchString(room) || root != "" && !eventIDPattern.MatchString(root) { |
| 4514 | + http.Error(w, "Invalid room or thread.", http.StatusBadRequest) |
| 4515 | + return |
| 4516 | + } |
| 4517 | + page := a.chatActivity(r.Context(), actor, room, root) |
| 4518 | + if page.Error != "" { |
| 4519 | + http.Error(w, page.Error, http.StatusForbidden) |
| 4520 | + return |
| 4521 | + } |
| 4522 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 4523 | + var body bytes.Buffer |
| 4524 | + if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil { |
| 4525 | + http.Error(w, "Activity rendering failed.", 500) |
| 4526 | + return |
| 4527 | + } |
| 4528 | + _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r)))) |
| 4529 | +} |
| 4530 | diff --git a/internal/webui/chat-activity.html b/internal/webui/chat-activity.html |
| 4531 | new file mode 100644 |
| 4532 | index 0000000..b28e482 |
| 4533 | --- /dev/null |
| 4534 | +++ b/internal/webui/chat-activity.html |
| 4535 | @@ -0,0 +1,8 @@ |
| 4536 | +{{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}} |
| 4537 | +{{define "chatActivityResponse"}}<div id="chat-activity-cards">{{template "chatActivityCards" .}}</div>{{end}} |
| 4538 | +{{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}}"> |
| 4539 | +{{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}} |
| 4540 | +<p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals?id={{.ID}}">Open request</a></p> |
| 4541 | +{{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}} |
| 4542 | +{{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}} |
| 4543 | +</article>{{end}}{{end}}{{end}} |
| 4544 | diff --git a/internal/webui/chat-activity.js b/internal/webui/chat-activity.js |
| 4545 | new file mode 100644 |
| 4546 | index 0000000..f44e1cb |
| 4547 | --- /dev/null |
| 4548 | +++ b/internal/webui/chat-activity.js |
| 4549 | @@ -0,0 +1,83 @@ |
| 4550 | +(() => { |
| 4551 | + "use strict"; |
| 4552 | + const tiny=globalThis.tiny; |
| 4553 | + class ChatActivity extends HTMLElement { |
| 4554 | + connectedCallback() { |
| 4555 | + if (this.running) return; |
| 4556 | + this.running=true; this.stopped=false; this.version=""; |
| 4557 | + this.onEvent=e=>{ if (e.detail?.room===this.getAttribute("room")) this.schedule(); }; |
| 4558 | + this.onVisibility=()=>{ if (!document.hidden) this.refresh(); }; |
| 4559 | + this.onLogout=()=>{ this.stopped=true; this.abort?.(); this.replaceChildren(); }; |
| 4560 | + this.onConfig=e=>{ |
| 4561 | + const cfg=e.detail?.cfg; |
| 4562 | + if(e.target!==this || cfg?.trigger?.target!==this) return; |
| 4563 | + const force=this.forceRefresh; |
| 4564 | + cfg.credentials="same-origin"; cfg.cache="no-store"; cfg.transition=false; |
| 4565 | + cfg.headers={...cfg.headers,Accept:"text/html"}; |
| 4566 | + this.abort=cfg.abort; |
| 4567 | + cfg.swap=()=>this.swap(cfg,force); |
| 4568 | + }; |
| 4569 | + this.onFinally=e=>{ if(e.target===this) { this.loading=false; this.abort=null; queueMicrotask(()=>{this.resolveRefresh?.();this.resolveRefresh=null;}); } }; |
| 4570 | + this.onError=e=>{ if(e.target===this && e.detail?.error?.name!=="AbortError") this.setAttribute("title","Chat activity could not be refreshed."); }; |
| 4571 | + document.addEventListener("tiny:room-event",this.onEvent); |
| 4572 | + document.addEventListener("visibilitychange",this.onVisibility); |
| 4573 | + document.addEventListener("tiny:logout",this.onLogout); |
| 4574 | + this.addEventListener("fx:config",this.onConfig); |
| 4575 | + this.addEventListener("fx:finally",this.onFinally); |
| 4576 | + this.addEventListener("fx:error",this.onError); |
| 4577 | + this.timer=setInterval(()=>this.refresh(),15000); |
| 4578 | + queueMicrotask(()=>{if(this.isConnected && !this.stopped){this.dispatchEvent(new CustomEvent("fx:process",{bubbles:true}));this.refresh();}}); |
| 4579 | + } |
| 4580 | + disconnectedCallback() { |
| 4581 | + this.running=false;this.stopped=true;this.abort?.();clearInterval(this.timer);clearTimeout(this.debounce); |
| 4582 | + document.removeEventListener("tiny:room-event",this.onEvent);document.removeEventListener("visibilitychange",this.onVisibility);document.removeEventListener("tiny:logout",this.onLogout); |
| 4583 | + this.removeEventListener("fx:config",this.onConfig);this.removeEventListener("fx:finally",this.onFinally);this.removeEventListener("fx:error",this.onError); |
| 4584 | + this.resolveRefresh?.();this.resolveRefresh=null;this.loading=false; |
| 4585 | + } |
| 4586 | + schedule() { if(!this.debounce)this.debounce=setTimeout(()=>{this.debounce=null;this.refresh();},400); } |
| 4587 | + async read() { |
| 4588 | + const url=new URL(tiny.localPath(this.getAttribute("endpoint")),location.href); |
| 4589 | + if(url.origin!==location.origin)throw Error("Invalid activity address."); |
| 4590 | + const response=await fetch(url,{credentials:"same-origin",cache:"no-store"}); |
| 4591 | + if(!response.ok)throw Error("Chat activity could not be refreshed."); |
| 4592 | + return response.text(); |
| 4593 | + } |
| 4594 | + async refresh(force=false) { |
| 4595 | + if(this.loading || this.stopped || document.hidden || (!force && this.contains(document.activeElement)) || !this.__fixi)return; |
| 4596 | + this.loading=true;this.forceRefresh=force; |
| 4597 | + return new Promise(resolve=>{this.resolveRefresh=resolve;this.dispatchEvent(new CustomEvent("tiny:activity-refresh"));}); |
| 4598 | + } |
| 4599 | + swap(cfg,force=false) { |
| 4600 | + if(this.stopped || !this.isConnected || !cfg.response.ok || cfg.text===this.version || (!force && this.contains(document.activeElement)))return; |
| 4601 | + const opened=new Set([...this.querySelectorAll("details[open][data-task]")].map(el=>el.dataset.task)); |
| 4602 | + morph(cfg.target,cfg.text); |
| 4603 | + cfg.target.querySelectorAll("details[data-task]").forEach(el=>{el.open=opened.has(el.dataset.task);}); |
| 4604 | + cfg.target.querySelectorAll("chat-decision").forEach(el=>{if(!el.form?.isConnected){el.form=null;el.output=null;el.connectedCallback();}}); |
| 4605 | + this.version=cfg.text; |
| 4606 | + } |
| 4607 | + } |
| 4608 | + class ChatDecision extends tiny.ui.FormElement { |
| 4609 | + connectedCallback() { if(this.form) return; super.connectedCallback(); } |
| 4610 | + async submit(form) { |
| 4611 | + const host=this.closest("chat-activity"),id=this.getAttribute("event"),author=this.getAttribute("pubkey"),actor=this.getAttribute("actor"),room=this.getAttribute("room"); |
| 4612 | + const signer=tiny.signer(); |
| 4613 | + if(!signer || await signer.getPublicKey()!==actor || tiny.signer()!==signer) throw Error("Connect the signer for this account."); |
| 4614 | + const fresh=document.createElement("template"); fresh.innerHTML=await host.read(); |
| 4615 | + const allowed=[...fresh.content.querySelectorAll("chat-decision")].find(el=>el.getAttribute("event")===id && el.getAttribute("actor")===actor && el.getAttribute("pubkey")===author); |
| 4616 | + if(!allowed) { host.schedule(); throw Error("This request is no longer waiting for your answer."); } |
| 4617 | + let unsigned; |
| 4618 | + if(this.hasAttribute("question")) { |
| 4619 | + const content=(form.elements.content?.value || "").trim(); if(!content) throw Error("Write an answer first."); |
| 4620 | + 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}; |
| 4621 | + } else { |
| 4622 | + const choice=this.submitter?.value; |
| 4623 | + if(!["+","-"].includes(choice)) throw Error("Choose Approve or Deny."); |
| 4624 | + unsigned={kind:7,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["e",id,"",author],["p",author],["k",this.getAttribute("kind")]],content:choice}; |
| 4625 | + } |
| 4626 | + this.report("Signing…"); await tiny.signing.publish(unsigned); |
| 4627 | + this.report("Answer sent."); this.querySelectorAll("button,textarea").forEach(el=>el.disabled=true); |
| 4628 | + await host.refresh(true); |
| 4629 | + } |
| 4630 | + } |
| 4631 | + customElements.define("chat-activity",ChatActivity); customElements.define("chat-decision",ChatDecision); |
| 4632 | +})(); |
| 4633 | diff --git a/internal/webui/chat-presence.js b/internal/webui/chat-presence.js |
| 4634 | new file mode 100644 |
| 4635 | index 0000000..cff7ab6 |
| 4636 | --- /dev/null |
| 4637 | +++ b/internal/webui/chat-presence.js |
| 4638 | @@ -0,0 +1,159 @@ |
| 4639 | +(() => { |
| 4640 | + "use strict"; |
| 4641 | + const tiny = globalThis.tiny; |
| 4642 | + const PRESENCE = 20001, TYPING = 20002; |
| 4643 | + const hex = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value); |
| 4644 | + const tag = (event, name) => (event.tags || []).find(item => item[0] === name)?.[1] || ""; |
| 4645 | + const seconds = () => Math.floor(Date.now() / 1000); |
| 4646 | + const parse = (event, room, now = seconds()) => { |
| 4647 | + if (!event || ![PRESENCE, TYPING].includes(event.kind) || !hex(event.pubkey)) return null; |
| 4648 | + const scope = tag(event, "h"); |
| 4649 | + if (scope !== room && !(event.kind === PRESENCE && !scope)) return null; |
| 4650 | + const ttl = event.kind === PRESENCE ? 180 : 5; |
| 4651 | + if (!Number.isSafeInteger(event.created_at) || event.created_at > now + 5 || event.created_at + ttl <= now) return null; |
| 4652 | + let status = String(event.content || "").trim(); |
| 4653 | + 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 {} |
| 4654 | + if (event.kind === TYPING) status = ["stopped", "false", "idle", "offline"].includes(status.toLowerCase()) ? "stopped" : "typing"; |
| 4655 | + return {pubkey:event.pubkey, kind:event.kind, status:status.slice(0,128) || "online", at:event.created_at, expires:event.created_at+ttl}; |
| 4656 | + }; |
| 4657 | + const readPreference = async signal => { |
| 4658 | + const response = await fetch(tiny.localPath("/chat/preferences"), {credentials:"same-origin",cache:"no-store",signal}); |
| 4659 | + if (!response.ok) throw Error("Chat preferences could not be loaded."); |
| 4660 | + return (await response.json()).share_presence === true; |
| 4661 | + }; |
| 4662 | + class ChatPreferences extends HTMLElement { |
| 4663 | + connectedCallback() { |
| 4664 | + this.actor = this.getAttribute("actor"); this.input = this.querySelector("input"); this.output = this.querySelector("output"); |
| 4665 | + if (!hex(this.actor) || !this.input) return; |
| 4666 | + this.controller = new AbortController(); this.input.disabled = true; |
| 4667 | + this.onChange = () => this.save(); |
| 4668 | + this.input.addEventListener("change",this.onChange); |
| 4669 | + this.onLogout = () => { this.controller.abort(); this.input.disabled = true; }; |
| 4670 | + document.addEventListener("tiny:logout",this.onLogout); |
| 4671 | + this.load(); |
| 4672 | + } |
| 4673 | + disconnectedCallback() { this.controller?.abort(); this.input?.removeEventListener("change",this.onChange); document.removeEventListener("tiny:logout",this.onLogout); } |
| 4674 | + async load() { |
| 4675 | + try { |
| 4676 | + this.saved = await readPreference(this.controller.signal); |
| 4677 | + if (!this.isConnected || this.controller.signal.aborted) return; |
| 4678 | + this.input.checked = this.saved; this.input.disabled = false; |
| 4679 | + } catch(error) { if (error.name !== "AbortError") this.output.textContent = error.message; } |
| 4680 | + } |
| 4681 | + async save() { |
| 4682 | + if (this.saving) return; |
| 4683 | + this.saving = true; this.input.disabled = true; |
| 4684 | + try { |
| 4685 | + const signer = tiny.signer(); |
| 4686 | + if (!signer || await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) throw Error("Connect the signer for this account."); |
| 4687 | + if (!this.isConnected || this.controller.signal.aborted) return; |
| 4688 | + const response = await tiny.signedFetch("/chat/preferences","POST",JSON.stringify({share_presence:this.input.checked}),{contentType:"application/json",signal:this.controller.signal}); |
| 4689 | + this.saved = (await response.json()).share_presence === true; |
| 4690 | + if (!this.isConnected || this.controller.signal.aborted) return; |
| 4691 | + this.input.checked = this.saved; this.output.textContent = "Saved."; |
| 4692 | + document.dispatchEvent(new CustomEvent("tiny:chat-preferences",{detail:{actor:this.actor,share_presence:this.saved}})); |
| 4693 | + } catch(error) { |
| 4694 | + this.input.checked = this.saved; |
| 4695 | + if (error.name !== "AbortError") this.output.textContent = error.message; |
| 4696 | + } finally { this.saving = false; this.input.disabled = this.controller.signal.aborted; } |
| 4697 | + } |
| 4698 | + } |
| 4699 | + class ChatPresence extends HTMLElement { |
| 4700 | + connectedCallback() { |
| 4701 | + this.room = this.getAttribute("room"); this.actor = this.getAttribute("actor"); |
| 4702 | + if (!/^[a-z0-9_-]{1,64}$/.test(this.room || "")) return; |
| 4703 | + this.values = new Map(); this.entries = new Map(); this.last = {}; this.pending = new Set(); this.generation = 0; |
| 4704 | + this.status = this.querySelector("output"); this.controller = new AbortController(); |
| 4705 | + this.enabled = false; |
| 4706 | + this.onEvent = event => { if (event.detail?.room === this.room) this.receive(event.detail.event); }; |
| 4707 | + this.onInput = event => { if (event.target.matches('room-compose textarea')) { this.inputAt = seconds(); this.publish(TYPING, event.target.value ? "typing" : "stopped"); } }; |
| 4708 | + 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(); } }; |
| 4709 | + this.onIdentity = () => { this.enabled = false; this.generation++; this.values.clear(); this.paint(); }; |
| 4710 | + this.onSigner = () => { this.onIdentity(); this.failed = false; this.loadPreference(); }; |
| 4711 | + this.onPreference = event => { if (event.detail?.actor === this.actor) this.applyPreference(event.detail.share_presence === true); }; |
| 4712 | + document.addEventListener("tiny:room-event", this.onEvent); |
| 4713 | + document.addEventListener("input", this.onInput); |
| 4714 | + document.addEventListener("visibilitychange", this.onVisibility); |
| 4715 | + document.addEventListener("tiny:signer", this.onSigner); |
| 4716 | + document.addEventListener("tiny:logout", this.onIdentity); |
| 4717 | + document.addEventListener("tiny:chat-preferences",this.onPreference); |
| 4718 | + this.loadPreference(); |
| 4719 | + this.timer = setInterval(() => this.tick(),1000); |
| 4720 | + } |
| 4721 | + disconnectedCallback() { |
| 4722 | + this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); |
| 4723 | + this.enabled = false; this.generation++; this.controller?.abort(); clearInterval(this.timer); |
| 4724 | + document.removeEventListener("tiny:room-event",this.onEvent); document.removeEventListener("input",this.onInput); |
| 4725 | + document.removeEventListener("visibilitychange",this.onVisibility); document.removeEventListener("tiny:signer",this.onSigner); document.removeEventListener("tiny:logout",this.onIdentity); |
| 4726 | + document.removeEventListener("tiny:chat-preferences",this.onPreference); this.values?.clear(); |
| 4727 | + } |
| 4728 | + applyPreference(enabled) { |
| 4729 | + if (this.enabled === enabled) return; |
| 4730 | + this.enabled = enabled; this.generation++; |
| 4731 | + if (enabled) this.tick(); |
| 4732 | + else { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); } |
| 4733 | + } |
| 4734 | + async loadPreference() { |
| 4735 | + if (!hex(this.actor) || this.loadingPreference || this.failed || this.controller.signal.aborted) return; |
| 4736 | + this.loadingPreference = true; const generation = this.generation; |
| 4737 | + try { |
| 4738 | + const enabled = await readPreference(this.controller.signal); |
| 4739 | + if (this.isConnected && !this.controller.signal.aborted && generation === this.generation) this.applyPreference(enabled); |
| 4740 | + } catch { this.applyPreference(false); } |
| 4741 | + finally { this.loadingPreference = false; this.preferenceCheckedAt = seconds(); } |
| 4742 | + } |
| 4743 | + receive(event) { |
| 4744 | + const value = parse(event,this.room); if (!value || value.pubkey === this.actor) return; |
| 4745 | + const id = value.kind+":"+value.pubkey, previous = this.values.get(id); |
| 4746 | + if (previous && previous.at > value.at) return; |
| 4747 | + this.values.set(id,value); |
| 4748 | + while (this.values.size > 256) this.values.delete(this.values.keys().next().value); |
| 4749 | + this.paint(); |
| 4750 | + } |
| 4751 | + tick() { |
| 4752 | + if (document.hidden) return; |
| 4753 | + this.paint(); |
| 4754 | + if (seconds()-(this.preferenceCheckedAt || 0) >= 60) this.loadPreference(); |
| 4755 | + if (this.enabled) this.publish(PRESENCE,"online"); |
| 4756 | + } |
| 4757 | + async publish(kind, content, leaving = false) { |
| 4758 | + if ((!this.enabled && !leaving) || (!this.last[kind] && leaving) || (document.hidden && !leaving) || !hex(this.actor) || this.pending.has(kind)) return; |
| 4759 | + const at = seconds(), interval = kind === PRESENCE ? 60 : 3; |
| 4760 | + if (!leaving && at-(this.last[kind] || 0) < interval) return; |
| 4761 | + const signer = tiny.signer?.(); if (!signer?.signEvent) return; |
| 4762 | + const generation = this.generation; |
| 4763 | + this.pending.add(kind); |
| 4764 | + try { |
| 4765 | + if (await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) return; |
| 4766 | + if (!leaving && (generation !== this.generation || !this.isConnected || !this.enabled || document.hidden)) return; |
| 4767 | + await tiny.signing.publish({kind, created_at:at, tags:[["h",this.room]], content}); |
| 4768 | + this.last[kind] = at; |
| 4769 | + } catch { |
| 4770 | + this.enabled = false; this.failed = true; |
| 4771 | + if (this.status) this.status.textContent = "Presence could not be shared. Reconnect your signer to try again."; |
| 4772 | + } finally { this.pending.delete(kind); } |
| 4773 | + } |
| 4774 | + paint() { |
| 4775 | + if (!this.status) return; |
| 4776 | + const now = seconds(); for (const [id,value] of this.values) if (value.expires <= now) this.values.delete(id); |
| 4777 | + 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)); |
| 4778 | + const visible = values.filter(v => v.kind === TYPING || !typers.has(v.pubkey)).sort((a,b) => a.pubkey.localeCompare(b.pubkey)).slice(0,8); |
| 4779 | + const active = new Set(visible.map(value => value.pubkey)); |
| 4780 | + for (const [key, entry] of this.entries) if (!active.has(key)) { entry.node.remove(); this.entries.delete(key); } |
| 4781 | + visible.forEach((value,index) => { |
| 4782 | + let entry = this.entries.get(value.pubkey); |
| 4783 | + if (!entry) { |
| 4784 | + const node = document.createElement("span"), name = document.createElement("nostr-name"), status = document.createElement("span"); |
| 4785 | + name.setAttribute("pubkey",value.pubkey); name.textContent = value.pubkey.slice(0,12); |
| 4786 | + node.append(name,status); entry = {node,status}; this.entries.set(value.pubkey,entry); |
| 4787 | + } |
| 4788 | + const text = " "+(value.kind === TYPING ? "is typing…" : value.status); |
| 4789 | + if (entry.status.textContent !== text) entry.status.textContent = text; |
| 4790 | + if (this.status.children[index] !== entry.node) this.status.insertBefore(entry.node,this.status.children[index] || null); |
| 4791 | + }); |
| 4792 | + } |
| 4793 | + } |
| 4794 | + tiny.roomPresence = Object.freeze({parse, PRESENCE,TYPING}); |
| 4795 | + customElements.define("chat-presence",ChatPresence); |
| 4796 | + customElements.define("chat-preferences",ChatPreferences); |
| 4797 | +})(); |
| 4798 | diff --git a/internal/webui/chat_activity_http_test.go b/internal/webui/chat_activity_http_test.go |
| 4799 | new file mode 100644 |
| 4800 | index 0000000..e8ada7e |
| 4801 | --- /dev/null |
| 4802 | +++ b/internal/webui/chat_activity_http_test.go |
| 4803 | @@ -0,0 +1,83 @@ |
| 4804 | +package webui |
| 4805 | + |
| 4806 | +import ( |
| 4807 | + "context" |
| 4808 | + "errors" |
| 4809 | + "net/http" |
| 4810 | + "net/http/httptest" |
| 4811 | + "strings" |
| 4812 | + "testing" |
| 4813 | + |
| 4814 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 4815 | +) |
| 4816 | + |
| 4817 | +type activityHTTPBackend struct { |
| 4818 | + roomsBackend |
| 4819 | + seenActor, seenRoom, seenRoot string |
| 4820 | + denied bool |
| 4821 | +} |
| 4822 | + |
| 4823 | +func (b *activityHTTPBackend) ReadChatActivity(_ context.Context, actor, room, root string) (any, error) { |
| 4824 | + b.seenActor, b.seenRoom, b.seenRoot = actor, room, root |
| 4825 | + if b.denied { |
| 4826 | + return nil, errors.New("private details that must stay hidden") |
| 4827 | + } |
| 4828 | + 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"}}}, nil |
| 4829 | +} |
| 4830 | + |
| 4831 | +func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) { |
| 4832 | + b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}} |
| 4833 | + actor := roomOwner |
| 4834 | + app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }}) |
| 4835 | + if err != nil { |
| 4836 | + t.Fatal(err) |
| 4837 | + } |
| 4838 | + req := httptest.NewRequest(http.MethodGet, "/chat/activity?room=general&root="+roomThread, nil) |
| 4839 | + req.RequestURI = "/r/team/chat/activity?room=general&root=" + roomThread |
| 4840 | + res := httptest.NewRecorder() |
| 4841 | + app.ServeHTTP(res, req) |
| 4842 | + body := res.Body.String() |
| 4843 | + if res.Code != 200 || res.Header().Get("Cache-Control") != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread { |
| 4844 | + t.Fatalf("request context: %d %v %#v", res.Code, res.Header(), b) |
| 4845 | + } |
| 4846 | + for _, want := range []string{`<chat-decision`, `href="/r/team/approvals?id=` + roomReply, `Approve <script>bad()</script>`, `<noscript>`} { |
| 4847 | + if !strings.Contains(body, want) { |
| 4848 | + t.Errorf("missing %q in %s", want, body) |
| 4849 | + } |
| 4850 | + } |
| 4851 | + if strings.Contains(body, "class=") || strings.Contains(body, "<script>bad()") { |
| 4852 | + t.Fatal("unsafe markup") |
| 4853 | + } |
| 4854 | + actor = roomGuest |
| 4855 | + res = httptest.NewRecorder() |
| 4856 | + app.ServeHTTP(res, req) |
| 4857 | + if strings.Contains(res.Body.String(), "chat-decision") || strings.Contains(res.Body.String(), "Review the preview") { |
| 4858 | + t.Fatal("approval shown to unaddressed actor") |
| 4859 | + } |
| 4860 | + b.denied = true |
| 4861 | + res = httptest.NewRecorder() |
| 4862 | + app.ServeHTTP(res, req) |
| 4863 | + if res.Code != http.StatusForbidden || strings.Contains(res.Body.String(), "private details") { |
| 4864 | + t.Fatalf("private error leaked: %d %s", res.Code, res.Body.String()) |
| 4865 | + } |
| 4866 | +} |
| 4867 | + |
| 4868 | +func TestChatActivityEndpointRejectsInvalidRoutesAndWrites(t *testing.T) { |
| 4869 | + app, _ := roomsApp(t, roomOwner) |
| 4870 | + for _, test := range []struct { |
| 4871 | + method, path string |
| 4872 | + status int |
| 4873 | + }{ |
| 4874 | + {http.MethodPost, "/chat/activity?room=general", 405}, |
| 4875 | + {http.MethodGet, "/chat/activity?room=../private", 400}, |
| 4876 | + {http.MethodGet, "/chat/activity?room=general&root=bad", 400}, |
| 4877 | + } { |
| 4878 | + t.Run(test.method+test.path, func(t *testing.T) { |
| 4879 | + res := httptest.NewRecorder() |
| 4880 | + app.ServeHTTP(res, httptest.NewRequest(test.method, test.path, nil)) |
| 4881 | + if res.Code != test.status { |
| 4882 | + t.Fatalf("status %d", res.Code) |
| 4883 | + } |
| 4884 | + }) |
| 4885 | + } |
| 4886 | +} |
| 4887 | diff --git a/internal/webui/chat_activity_test.go b/internal/webui/chat_activity_test.go |
| 4888 | new file mode 100644 |
| 4889 | index 0000000..3fcdff3 |
| 4890 | --- /dev/null |
| 4891 | +++ b/internal/webui/chat_activity_test.go |
| 4892 | @@ -0,0 +1,58 @@ |
| 4893 | +package webui |
| 4894 | + |
| 4895 | +import ( |
| 4896 | + "strings" |
| 4897 | + "testing" |
| 4898 | +) |
| 4899 | + |
| 4900 | +func TestChatActivityViewScopesDecisionsAndPreservesJobSummary(t *testing.T) { |
| 4901 | + actor := strings.Repeat("a", 64) |
| 4902 | + other := strings.Repeat("b", 64) |
| 4903 | + jobs := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("1", 64), "requester": other, "status": "working", "content": "Indexing files"}}} |
| 4904 | + approvals := map[string]any{"items": []any{ |
| 4905 | + map[string]any{"id": strings.Repeat("2", 64), "asker": other, "asked": []any{actor}, "subject": "Publish result", "state": "open", "kind": "4550"}, |
| 4906 | + map[string]any{"id": strings.Repeat("3", 64), "asker": other, "asked": []any{other}, "subject": "Private request", "state": "open", "kind": "4550"}, |
| 4907 | + }} |
| 4908 | + items := chatActivityView(jobs, approvals, actor, "/chat/activity", "general") |
| 4909 | + if len(items) != 2 { |
| 4910 | + t.Fatalf("got %d activity items, want job and addressed approval", len(items)) |
| 4911 | + } |
| 4912 | + if items[0].Type != "job" || items[0].Status != "working" || items[0].Title != "Indexing files" { |
| 4913 | + t.Fatalf("unexpected job card: %#v", items[0]) |
| 4914 | + } |
| 4915 | + if items[1].Type != "approval" || !items[1].CanDecide || items[1].ID != strings.Repeat("2", 64) { |
| 4916 | + t.Fatalf("unexpected approval card: %#v", items[1]) |
| 4917 | + } |
| 4918 | +} |
| 4919 | + |
| 4920 | +func TestChatActivityViewDisablesAnsweredApproval(t *testing.T) { |
| 4921 | + actor := strings.Repeat("a", 64) |
| 4922 | + approvals := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("2", 64), "asker": actor, "asked": []any{actor}, "content": "Done", "state": "answered"}}} |
| 4923 | + items := chatActivityView(nil, approvals, actor, "/chat/activity", "general") |
| 4924 | + if len(items) != 1 || items[0].CanDecide { |
| 4925 | + t.Fatalf("answered approval remained actionable: %#v", items) |
| 4926 | + } |
| 4927 | +} |
| 4928 | + |
| 4929 | +func TestChatActivityAskerCannotApproveOwnRequest(t *testing.T) { |
| 4930 | + actor := strings.Repeat("a", 64) |
| 4931 | + 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"}}} |
| 4932 | + items := chatActivityView(nil, approvals, actor, "/chat/activity", "main") |
| 4933 | + if len(items) != 1 || items[0].CanDecide { |
| 4934 | + t.Fatalf("asker allowed to decide: %#v", items) |
| 4935 | + } |
| 4936 | +} |
| 4937 | + |
| 4938 | +func TestChatActivityPreservesAnsweredQuestion(t *testing.T) { |
| 4939 | + actor := strings.Repeat("a", 64) |
| 4940 | + other := strings.Repeat("b", 64) |
| 4941 | + approvals := map[string]any{"items": []any{map[string]any{ |
| 4942 | + "id": strings.Repeat("4", 64), "asker": other, "asked": []string{actor}, |
| 4943 | + "type": "question", "subject": "Which preview?", "state": "answered", |
| 4944 | + "answer": map[string]any{"decision": "replied", "content": "Use the dark preview."}, |
| 4945 | + }}} |
| 4946 | + items := chatActivityView(nil, approvals, actor, "/chat/activity", "general") |
| 4947 | + if len(items) != 1 || items[0].Answer != "Use the dark preview." || items[0].CanDecide { |
| 4948 | + t.Fatalf("answered question = %#v", items) |
| 4949 | + } |
| 4950 | +} |
| 4951 | diff --git a/internal/webui/chat_pages.go b/internal/webui/chat_pages.go |
| 4952 | index 3752224..3c74706 100644 |
| 4953 | --- a/internal/webui/chat_pages.go |
| 4954 | +++ b/internal/webui/chat_pages.go |
| 4955 | @@ -23,6 +23,10 @@ func (a *App) handleChatRoute(w http.ResponseWriter, r *http.Request) bool { |
| 4956 | http.Redirect(w, r, target, http.StatusMovedPermanently) |
| 4957 | return true |
| 4958 | } |
| 4959 | + if path == "/chat/activity" { |
| 4960 | + a.chatActivityHTTP(w, r) |
| 4961 | + return true |
| 4962 | + } |
| 4963 | if path == "/chat/events" { |
| 4964 | a.directMessagesJSON(w, r) |
| 4965 | return true |
| 4966 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 4967 | index 6e69eea..f26b5cd 100644 |
| 4968 | --- a/internal/webui/components.js |
| 4969 | +++ b/internal/webui/components.js |
| 4970 | @@ -1667,7 +1667,7 @@ |
| 4971 | if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null; |
| 4972 | return root ? url.pathname.slice(root.length) || "/" : url.pathname; |
| 4973 | }; |
| 4974 | - 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 || ""); |
| 4975 | + 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 || ""); |
| 4976 | let navigationSerial = 0, activeAbort; |
| 4977 | const streams = new Set(); |
| 4978 | const closeStreams = () => { |
| 4979 | @@ -1682,7 +1682,7 @@ |
| 4980 | document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg)); |
| 4981 | window.addEventListener("pagehide", closeStreams); |
| 4982 | const repairComponents = () => { |
| 4983 | - 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 => { |
| 4984 | + 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 => { |
| 4985 | if (node.form?.isConnected) return; |
| 4986 | node.form = null; |
| 4987 | node.output = null; |
| 4988 | diff --git a/internal/webui/grant-request.js b/internal/webui/grant-request.js |
| 4989 | new file mode 100644 |
| 4990 | index 0000000..fa90671 |
| 4991 | --- /dev/null |
| 4992 | +++ b/internal/webui/grant-request.js |
| 4993 | @@ -0,0 +1,64 @@ |
| 4994 | +(() => { |
| 4995 | + "use strict"; |
| 4996 | + const tiny = globalThis.tiny; |
| 4997 | + 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; }); |
| 4998 | + class GrantRequest extends HTMLElement { |
| 4999 | + connectedCallback() { |
| 5000 | + if (!this.onClick) { |
| 5001 | + this.onClick = event => { |
| 5002 | + const button = event.target.closest("[data-grant-approve], [data-grant-deny]"); |
| 5003 | + if (button && this.contains(button)) this.decide(button, button.hasAttribute("data-grant-approve")); |
| 5004 | + }; |
| 5005 | + this.addEventListener("click", this.onClick); |
| 5006 | + } |
| 5007 | + this.setBusy(false); |
| 5008 | + } |
| 5009 | + disconnectedCallback() { this.controller?.abort(); } |
| 5010 | + setBusy(busy) { |
| 5011 | + this.busy = busy; |
| 5012 | + this.querySelectorAll("button").forEach(button => { |
| 5013 | + button.disabled = busy || (button.hasAttribute("data-grant-approve") && this.hasAttribute("data-stale")); |
| 5014 | + }); |
| 5015 | + } |
| 5016 | + report(text) { |
| 5017 | + let output = this.querySelector("output"); |
| 5018 | + if (!output) { output = document.createElement("output"); output.setAttribute("role", "status"); this.append(output); } |
| 5019 | + output.textContent = text; |
| 5020 | + } |
| 5021 | + async latest(id) { |
| 5022 | + const query = new URLSearchParams({method: "browseapproval", params: JSON.stringify([{id}])}); |
| 5023 | + const response = await fetch(tiny.localPath("/webmcp/query") + "?" + query, {credentials: "same-origin", cache: "no-store", signal: this.controller.signal}); |
| 5024 | + const value = await response.json(); |
| 5025 | + if (!response.ok || value?.error) throw Error(value?.error?.message || value?.error || "Could not refresh this request."); |
| 5026 | + return value.result || value; |
| 5027 | + } |
| 5028 | + async checkSigner(signer, operator) { |
| 5029 | + if (!signer?.getPublicKey || await signer.getPublicKey() !== operator || tiny.signer() !== signer) throw Error("Connect the requested operator's signer."); |
| 5030 | + if (!this.isConnected || this.controller.signal.aborted) throw Error("This request is no longer open."); |
| 5031 | + } |
| 5032 | + async decide(button, approve) { |
| 5033 | + if (this.busy) return; |
| 5034 | + this.setBusy(true); |
| 5035 | + this.controller = new AbortController(); |
| 5036 | + try { |
| 5037 | + const review = JSON.parse(this.getAttribute("data-review") || "{}"); |
| 5038 | + const signer = tiny.signer(); |
| 5039 | + await this.checkSigner(signer, review.operator); |
| 5040 | + this.report("Checking current permissions…"); |
| 5041 | + const {item} = await this.latest(review.request_id), fresh = item?.grant; |
| 5042 | + 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."); |
| 5043 | + 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."); |
| 5044 | + await this.checkSigner(signer, review.operator); |
| 5045 | + 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: "-"}; |
| 5046 | + 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."); |
| 5047 | + this.report(approve ? "Signing grant…" : "Signing denial…"); |
| 5048 | + await tiny.signing.publish({kind:unsigned.kind,created_at:unsigned.created_at,tags:unsigned.tags,content:unsigned.content}); |
| 5049 | + this.report(approve ? "Grant updated." : "Request denied."); |
| 5050 | + if (this.isConnected) await tiny.navigate(location.href); |
| 5051 | + } catch (error) { |
| 5052 | + if (this.isConnected) { this.report(error.message || String(error)); this.setBusy(false); } |
| 5053 | + } |
| 5054 | + } |
| 5055 | + } |
| 5056 | + customElements.define("agent-grant-request", GrantRequest); |
| 5057 | +})(); |
| 5058 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 5059 | index ec0e301..6c4aea5 100644 |
| 5060 | --- a/internal/webui/page.html |
| 5061 | +++ b/internal/webui/page.html |
| 5062 | @@ -49,6 +49,7 @@ then feature modules are loaded for the current page. */}} |
| 5063 | {{else if eq .Tab "rules"}}{{template "rules" .}} |
| 5064 | {{else if eq .Tab "identity"}}{{template "identity" .}} |
| 5065 | {{else if eq .Tab "signin"}}{{template "signin" .}} |
| 5066 | +{{else if eq .Tab "account"}}{{template "account" .}} |
| 5067 | {{else if eq .Tab "connect"}}{{template "connect" .}} |
| 5068 | {{else if eq .Tab "data"}}{{template "data" .}} |
| 5069 | {{else if eq .Tab "sync"}}{{template "sync" .}} |
| 5070 | @@ -73,7 +74,7 @@ then feature modules are loaded for the current page. */}} |
| 5071 | {{end}}</div></td> |
| 5072 | <td id="aside"><div id="panel">{{template "panel" .}}</div></td> |
| 5073 | </tr></tbody></table> |
| 5074 | -<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> » <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> |
| 5075 | +<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> » <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> |
| 5076 | <script src="{{scriptURL "tiny.js"}}"></script> |
| 5077 | <script src="{{scriptURL "bridge.js"}}"></script> |
| 5078 | {{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script> |
| 5079 | @@ -88,6 +89,9 @@ then feature modules are loaded for the current page. */}} |
| 5080 | <script src="{{scriptURL "direct-messages.js"}}"></script> |
| 5081 | <script src="{{scriptURL "chat-files.js"}}"></script> |
| 5082 | <script src="{{scriptURL "chat.js"}}"></script> |
| 5083 | +<script src="{{scriptURL "chat-presence.js"}}"></script> |
| 5084 | +<script src="{{scriptURL "chat-activity.js"}}"></script> |
| 5085 | +<script src="{{scriptURL "grant-request.js"}}"></script> |
| 5086 | {{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script> |
| 5087 | {{end}}</body> |
| 5088 | </html> |
| 5089 | @@ -109,7 +113,7 @@ then feature modules are loaded for the current page. */}} |
| 5090 | <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> |
| 5091 | <p><a href="/chat#new-room">+ new room</a></p> |
| 5092 | {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header> |
| 5093 | -{{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}} |
| 5094 | +{{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}} |
| 5095 | {{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">← all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">← relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}} |
| 5096 | |
| 5097 | {{/* The panel holds facts and filters for the current page. */}} |
| 5098 | @@ -126,6 +130,7 @@ then feature modules are loaded for the current page. */}} |
| 5099 | {{else if eq .Tab "approvals"}}{{template "panel-approvals" .}} |
| 5100 | {{else if or (eq .Tab "wiki") (eq .Tab "wikipage")}}{{template "panel-wiki" .}} |
| 5101 | {{else if eq .Tab "signin"}}{{template "panel-signin" .}} |
| 5102 | +{{else if eq .Tab "account"}}{{template "panel-account" .}} |
| 5103 | {{else}}{{template "panel-relay" .}} |
| 5104 | {{end}}{{end}} |
| 5105 | |
| 5106 | @@ -136,6 +141,8 @@ then feature modules are loaded for the current page. */}} |
| 5107 | {{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> |
| 5108 | <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}} |
| 5109 | |
| 5110 | +{{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}} |
| 5111 | + |
| 5112 | {{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> |
| 5113 | <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}} |
| 5114 | |
| 5115 | @@ -167,8 +174,9 @@ then feature modules are loaded for the current page. */}} |
| 5116 | <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> |
| 5117 | {{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section> |
| 5118 | {{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> |
| 5119 | +<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> |
| 5120 | {{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section> |
| 5121 | -{{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> |
| 5122 | +{{else}} |
| 5123 | {{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> |
| 5124 | <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> |
| 5125 | {{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> |
| 5126 | diff --git a/internal/webui/private.go b/internal/webui/private.go |
| 5127 | index 2125cfe..339450c 100644 |
| 5128 | --- a/internal/webui/private.go |
| 5129 | +++ b/internal/webui/private.go |
| 5130 | @@ -19,7 +19,7 @@ type PrivateReader interface { |
| 5131 | func privatePolicyEnabled(p policy.Policy) bool { return p.Features.Grasp08 } |
| 5132 | |
| 5133 | func privateShellPath(path string) bool { |
| 5134 | - if path == "/" || path == "/signin" || path == "/manage/connect" { |
| 5135 | + if path == "/" || path == "/signin" || path == "/account" || path == "/manage/connect" { |
| 5136 | return true |
| 5137 | } |
| 5138 | if path == "/manage/rpc" || path == "/manage/jobs/status" { |
| 5139 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 5140 | index 796d75c..5eae294 100644 |
| 5141 | --- a/internal/webui/public.html |
| 5142 | +++ b/internal/webui/public.html |
| 5143 | @@ -29,26 +29,29 @@ |
| 5144 | <h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1> |
| 5145 | {{if .Notice}}<p role="status">{{.Notice}}</p>{{end}} |
| 5146 | {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p> |
| 5147 | -<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/chat">Chat</a></p> |
| 5148 | +<p><a href="/account">Open your account</a> | <a href="/chat">Chat</a></p> |
| 5149 | <p>Use the sign out control in the footer to end this session.</p> |
| 5150 | -<details id="relay-setup"><summary>Your relay lists (optional)</summary> |
| 5151 | -<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> |
| 5152 | +{{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p> |
| 5153 | +{{template "signerControls" .}} |
| 5154 | +{{end}}</section> |
| 5155 | +{{end}} |
| 5156 | + |
| 5157 | +{{define "account"}}<section id="account"> |
| 5158 | +<h1>Account</h1> |
| 5159 | +<p>Manage your profile, chat preferences and relay lists.</p> |
| 5160 | +<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> |
| 5161 | +<h2>Chat preferences</h2> |
| 5162 | +<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> |
| 5163 | +<h2>Your relay lists</h2> |
| 5164 | +<p>Choose where other Nostr clients find your notes, messages and files. Existing entries stay.</p> |
| 5165 | <relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}"> |
| 5166 | -<table id="rows"> |
| 5167 | -<tbody> |
| 5168 | +<table id="rows"><tbody> |
| 5169 | <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> |
| 5170 | <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> |
| 5171 | <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> |
| 5172 | <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> |
| 5173 | -</tbody> |
| 5174 | -</table> |
| 5175 | -<output role="status"></output> |
| 5176 | -</relay-lists> |
| 5177 | -</details> |
| 5178 | -{{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p> |
| 5179 | -{{template "signerControls" .}} |
| 5180 | -{{end}}</section> |
| 5181 | -{{end}} |
| 5182 | +</tbody></table><output role="status"></output></relay-lists> |
| 5183 | +</section>{{end}} |
| 5184 | |
| 5185 | {{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p> |
| 5186 | <h2>Phone signer</h2> |
| 5187 | diff --git a/internal/webui/read_contracts.go b/internal/webui/read_contracts.go |
| 5188 | index 54a712d..58b4a3e 100644 |
| 5189 | --- a/internal/webui/read_contracts.go |
| 5190 | +++ b/internal/webui/read_contracts.go |
| 5191 | @@ -55,10 +55,11 @@ type RoomSummary struct { |
| 5192 | } |
| 5193 | |
| 5194 | type RoomMember struct { |
| 5195 | - PubKey string `json:"pubkey"` |
| 5196 | - Role string `json:"role,omitempty"` |
| 5197 | - Agent bool `json:"agent,omitempty"` |
| 5198 | - AddedAt int64 `json:"added_at,omitempty"` |
| 5199 | + PubKey string `json:"pubkey"` |
| 5200 | + Role string `json:"role,omitempty"` |
| 5201 | + Agent bool `json:"agent,omitempty"` |
| 5202 | + Operator string `json:"operator,omitempty"` |
| 5203 | + AddedAt int64 `json:"added_at,omitempty"` |
| 5204 | } |
| 5205 | |
| 5206 | type RoomMessage = event.Event |
| 5207 | diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go |
| 5208 | index 28f2670..9e448ee 100644 |
| 5209 | --- a/internal/webui/rooms.go |
| 5210 | +++ b/internal/webui/rooms.go |
| 5211 | @@ -6,8 +6,11 @@ import ( |
| 5212 | "html/template" |
| 5213 | "regexp" |
| 5214 | "sort" |
| 5215 | + "strconv" |
| 5216 | "strings" |
| 5217 | "time" |
| 5218 | + |
| 5219 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 5220 | ) |
| 5221 | |
| 5222 | // roomPath is one rooms page: the list, one room, or one thread in a room. |
| 5223 | @@ -123,12 +126,8 @@ func memberIndex(value any) map[string]map[string]any { |
| 5224 | func replyCounts(value any) map[string]int { |
| 5225 | counts := map[string]int{} |
| 5226 | for _, row := range roomSlice(value, "messages") { |
| 5227 | - e := valueMap(row) |
| 5228 | - if plainString(e["kind"]) != "12" { |
| 5229 | - continue |
| 5230 | - } |
| 5231 | - if roots := tagValues(row, "e"); len(roots) > 0 { |
| 5232 | - counts[roots[0]]++ |
| 5233 | + if root := webRoomReplyRoot(row); root != "" { |
| 5234 | + counts[root]++ |
| 5235 | } |
| 5236 | } |
| 5237 | return counts |
| 5238 | @@ -289,13 +288,13 @@ func clock(value any) string { |
| 5239 | // roomItem is one message as the page shows it: the event's fields, the |
| 5240 | // author's markers, the thread state and the reactions it has received. |
| 5241 | type roomItem struct { |
| 5242 | - ID, PubKey, Avatar, Kind, Content, Room, Root, Quote, Role, Notice string |
| 5243 | - CreatedAt, UpdatedAt int64 |
| 5244 | - Agent, InThread, Edited, Own bool |
| 5245 | - Mentions []string |
| 5246 | - Replies int |
| 5247 | - Reactions []reaction |
| 5248 | - Attachments []roomAttachment |
| 5249 | + ID, PubKey, Avatar, Kind, Content, Room, Root, Quote, Role, Operator, Notice string |
| 5250 | + CreatedAt, UpdatedAt int64 |
| 5251 | + Agent, InThread, Edited, Own bool |
| 5252 | + Mentions []string |
| 5253 | + Replies int |
| 5254 | + Reactions []reaction |
| 5255 | + Attachments []roomAttachment |
| 5256 | } |
| 5257 | |
| 5258 | // roomItems turns a browse result's messages or replies into view items, |
| 5259 | @@ -312,7 +311,7 @@ func roomItems(value any, field string, actors ...string) []roomItem { |
| 5260 | items := make([]roomItem, 0, len(rows)) |
| 5261 | for i := len(rows) - 1; i >= 0; i-- { |
| 5262 | item, ok := newRoomItem(rows[i], room, members) |
| 5263 | - if !ok { |
| 5264 | + if !ok || field == "messages" && item.Root != "" { |
| 5265 | continue |
| 5266 | } |
| 5267 | // Only the author edits their own message, and only forward in time. |
| 5268 | @@ -374,10 +373,8 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI |
| 5269 | if item.Kind == "44101" { |
| 5270 | item.Notice = "left the room" |
| 5271 | } |
| 5272 | - case "12": |
| 5273 | - if roots := tagValues(row, "e"); len(roots) > 0 { |
| 5274 | - item.Root = roots[0] |
| 5275 | - } |
| 5276 | + case "9", "12": |
| 5277 | + item.Root = webRoomReplyRoot(row) |
| 5278 | } |
| 5279 | if len(item.PubKey) >= 2 { |
| 5280 | item.Avatar = strings.ToUpper(item.PubKey[:2]) |
| 5281 | @@ -385,6 +382,7 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI |
| 5282 | if member := members[item.PubKey]; member != nil { |
| 5283 | item.Role = plainString(member["role"]) |
| 5284 | item.Agent, _ = member["agent"].(bool) |
| 5285 | + item.Operator = plainString(member["operator"]) |
| 5286 | } |
| 5287 | if item.Notice == "" { |
| 5288 | for _, mention := range tagValues(row, "p") { |
| 5289 | @@ -401,3 +399,11 @@ func roomAdmin(role any) bool { |
| 5290 | value := plainString(role) |
| 5291 | return value == "owner" || value == "admin" |
| 5292 | } |
| 5293 | + |
| 5294 | +func webRoomReplyRoot(row any) string { |
| 5295 | + kind, err := strconv.Atoi(plainString(valueMap(row)["kind"])) |
| 5296 | + if err != nil { |
| 5297 | + return "" |
| 5298 | + } |
| 5299 | + return event.RoomReplyRoot(event.Event{Kind: kind, Tags: roomTags(row)}) |
| 5300 | +} |
| 5301 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 5302 | index d601ac3..145f737 100644 |
| 5303 | --- a/internal/webui/rooms.html |
| 5304 | +++ b/internal/webui/rooms.html |
| 5305 | @@ -31,10 +31,12 @@ establishes the initial state. */}} |
| 5306 | {{with str (index $room "about")}}<p>{{.}}</p>{{end}} |
| 5307 | {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}} |
| 5308 | {{if .Query.Get "cursor"}}<p id="newest"><a href="/rooms/{{$id}}">newest messages</a></p>{{end}} |
| 5309 | +{{template "chatActivity" .Activity}} |
| 5310 | <div id="messages">{{range roomItems .Event "messages" .Actor}} |
| 5311 | {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}} |
| 5312 | </div> |
| 5313 | {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live> |
| 5314 | +{{template "chatPresence" .}} |
| 5315 | {{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> |
| 5316 | <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript> |
| 5317 | {{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to send messages.</p> |
| 5318 | @@ -49,12 +51,14 @@ establishes the initial state. */}} |
| 5319 | <p id="crumbs"><a href="/rooms/{{$id}}">{{or (str (index $room "name")) $id}}</a></p> |
| 5320 | <h1>Thread <small>{{$root.Replies}} {{if eq $root.Replies 1}}reply{{else}}replies{{end}}</small></h1> |
| 5321 | <div id="root">{{template "roomMessage" $root}}</div> |
| 5322 | +{{template "chatActivity" .Activity}} |
| 5323 | <div id="messages">{{range roomItems .Event "replies" .Actor}} |
| 5324 | {{template "roomMessage" .}}{{else}}<p id="empty">No replies yet.</p>{{end}} |
| 5325 | </div> |
| 5326 | {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}} |
| 5327 | {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live> |
| 5328 | -{{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> |
| 5329 | +{{template "chatPresence" .}} |
| 5330 | +{{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> |
| 5331 | <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript> |
| 5332 | {{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to reply.</p> |
| 5333 | {{end}}{{end}}</section> |
| 5334 | @@ -65,7 +69,9 @@ establishes the initial state. */}} |
| 5335 | |
| 5336 | {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}} |
| 5337 | {{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> |
| 5338 | -<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> |
| 5339 | +<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> |
| 5340 | {{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}} |
| 5341 | -{{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}} |
| 5342 | +{{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}} |
| 5343 | </room-message>{{end}} |
| 5344 | + |
| 5345 | +{{define "chatPresence"}}<chat-presence room="{{.View}}" actor="{{.Actor}}"><output aria-live="polite"></output></chat-presence>{{end}} |
| 5346 | diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js |
| 5347 | index aeae3fa..8d4a245 100644 |
| 5348 | --- a/internal/webui/rooms.js |
| 5349 | +++ b/internal/webui/rooms.js |
| 5350 | @@ -325,10 +325,23 @@ |
| 5351 | const members = {}; |
| 5352 | document.querySelectorAll("#members li").forEach(item => { |
| 5353 | const hex = item.querySelector("nostr-name")?.getAttribute("pubkey") || item.querySelector("nostr-key")?.getAttribute("hex"); |
| 5354 | - if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent")}; |
| 5355 | + if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent"), operator: item.getAttribute("data-operator") || ""}; |
| 5356 | }); |
| 5357 | return members; |
| 5358 | }; |
| 5359 | + const replyRoot = event => { |
| 5360 | + if (![9, 12].includes(event?.kind)) return ""; |
| 5361 | + let root = "", reply = "", first = "", marked = false; |
| 5362 | + for (const tag of event.tags || []) { |
| 5363 | + if (tag[0] !== "e" || !isHex64(tag[1])) continue; |
| 5364 | + const marker = tag[3] || ""; |
| 5365 | + marked ||= Boolean(marker); |
| 5366 | + if (marker === "root") root = tag[1]; |
| 5367 | + else if (marker === "reply") reply = tag[1]; |
| 5368 | + else if (!marker && !first) first = tag[1]; |
| 5369 | + } |
| 5370 | + return root || reply || (!marked ? first : ""); |
| 5371 | + }; |
| 5372 | const messageNode = (event, {members = {}, room = "", inThread = false} = {}) => { |
| 5373 | const node = el("room-message"); |
| 5374 | const notice = event.kind === 44100 ? "joined the room" : event.kind === 44101 ? "left the room" : ""; |
| 5375 | @@ -354,7 +367,12 @@ |
| 5376 | small.append(time); |
| 5377 | header.append(name); |
| 5378 | if (own) { const label = el("span", "you"); label.dataset.ownLabel = ""; header.append(label); } |
| 5379 | - header.append(member?.role ? " | " + member.role : "", small); |
| 5380 | + if (member?.agent) { |
| 5381 | + header.append(" | "); |
| 5382 | + if (member.operator) header.append(nameNode(member.operator), "'s agent"); |
| 5383 | + else header.append("agent"); |
| 5384 | + } else if (member?.role) header.append(" | " + member.role); |
| 5385 | + header.append(small); |
| 5386 | const attachments = notice ? [] : roomAttachments(event); |
| 5387 | const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments)); |
| 5388 | node.append(avatar, header); |
| 5389 | @@ -365,9 +383,9 @@ |
| 5390 | const footer = el("footer"); |
| 5391 | const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]); |
| 5392 | if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(nameNode(key), " ")); footer.append(span); } |
| 5393 | - if (!inThread && room && event.kind === 11) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); } |
| 5394 | - const root = tagValue(event, "e"); |
| 5395 | - if (!inThread && room && event.kind === 12 && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); } |
| 5396 | + if (!inThread && room && [9, 11, 40002].includes(event.kind)) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); } |
| 5397 | + const root = replyRoot(event); |
| 5398 | + if (!inThread && room && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); } |
| 5399 | if (footer.childNodes.length) node.append(footer); |
| 5400 | return node; |
| 5401 | }; |
| 5402 | @@ -388,6 +406,16 @@ |
| 5403 | const roomAppend = (event, {room = "", inThread = false, own = false} = {}) => { |
| 5404 | const list = document.getElementById("messages"); |
| 5405 | if (!list || !isHex64(event?.id) || document.getElementById("msg-" + event.id)) return null; |
| 5406 | + list.roomSeen ||= new Set(); |
| 5407 | + if (list.roomSeen.has(event.id)) return null; |
| 5408 | + list.roomSeen.add(event.id); |
| 5409 | + while (list.roomSeen.size > 2000) list.roomSeen.delete(list.roomSeen.values().next().value); |
| 5410 | + const reference = replyRoot(event); |
| 5411 | + if (reference && !inThread) { |
| 5412 | + const link = document.querySelector("#msg-" + reference + " > footer > a"); |
| 5413 | + if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); } |
| 5414 | + return null; |
| 5415 | + } |
| 5416 | const follow = own || roomNearBottom(); |
| 5417 | list.querySelector("#empty")?.remove(); |
| 5418 | const node = messageNode(event, {members: panelMembers(), room, inThread}); |
| 5419 | @@ -575,10 +603,10 @@ |
| 5420 | const kind = Number(this.getAttribute("kind") || 9); |
| 5421 | if (kind !== 9 && kind !== 12) throw Error("Unsupported message kind."); |
| 5422 | const tags = [["h", room]]; |
| 5423 | - if (kind === 12) { |
| 5424 | + if (kind === 12 || this.getAttribute("root")) { |
| 5425 | const root = this.getAttribute("root"), author = this.getAttribute("root-pubkey"); |
| 5426 | if (!isHex64(root)) throw Error("The thread root is missing."); |
| 5427 | - tags.push(["e", root]); |
| 5428 | + tags.push(kind === 12 ? ["e", root] : ["e", root, "", "root"]); |
| 5429 | if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]); |
| 5430 | } |
| 5431 | const quote = this.getAttribute("quote"); |
| 5432 | @@ -650,7 +678,7 @@ |
| 5433 | this.renderFiles(); |
| 5434 | this.resizeContent(); |
| 5435 | this.report(""); |
| 5436 | - roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true}); |
| 5437 | + roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(this.getAttribute("root")), own: true}); |
| 5438 | } finally { this.uploadController = null; } |
| 5439 | } |
| 5440 | } |
| 5441 | @@ -760,16 +788,22 @@ |
| 5442 | |
| 5443 | receive(event) { |
| 5444 | if (!event || typeof event !== "object") return; |
| 5445 | + const room = this.getAttribute("room"); |
| 5446 | + if (tagValue(event, "h") !== room && !(event.kind === 20001 && !tagValue(event, "h"))) return; |
| 5447 | + if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:room-event", {detail: {room, event}})); |
| 5448 | if (event.kind === 7) { roomReact(event); return; } |
| 5449 | if (event.kind === 40003) { roomEdit(event); return; } |
| 5450 | if (!roomKinds.includes(event.kind)) return; |
| 5451 | const root = this.getAttribute("root"); |
| 5452 | - if (root && (event.kind !== 12 || tagValue(event, "e") !== root)) return; |
| 5453 | + if (root) { |
| 5454 | + const reference = replyRoot(event); |
| 5455 | + if (reference !== root && !document.getElementById("msg-" + reference)) return; |
| 5456 | + } |
| 5457 | roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)}); |
| 5458 | } |
| 5459 | } |
| 5460 | |
| 5461 | - tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes}); |
| 5462 | + tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, replyRoot, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes}); |
| 5463 | tiny.roomElements = Object.freeze({RoomCompose, RoomCreate, RoomAction, RoomLive}); |
| 5464 | customElements.define("room-compose", RoomCompose); |
| 5465 | customElements.define("room-create", RoomCreate); |
| 5466 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 5467 | index 9794e64..3ad6ddb 100644 |
| 5468 | --- a/internal/webui/rooms_test.go |
| 5469 | +++ b/internal/webui/rooms_test.go |
| 5470 | @@ -66,7 +66,7 @@ func (b *roomsBackend) Query(_ context.Context, method string, params []json.Raw |
| 5471 | default: |
| 5472 | return nil, nil |
| 5473 | } |
| 5474 | - members := []any{map[string]any{"pubkey": roomOwner, "role": "owner", "added_at": 1757200000}, map[string]any{"pubkey": roomAgent, "role": "member", "added_at": 1757200100, "agent": true}} |
| 5475 | + 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}} |
| 5476 | root := roomEvent(roomThread, roomAgent, 11, 1757203000, "Release notes draft", []string{"h", id}) |
| 5477 | reply := roomEvent(roomReply, roomOwner, 12, 1757203300, "Looks good", []string{"h", id}, []string{"e", roomThread}, []string{"p", roomAgent}) |
| 5478 | if method == "browsethread" { |
| 5479 | @@ -156,11 +156,10 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) { |
| 5480 | `<span data-reaction="+1">+1 1</span>`, |
| 5481 | `<div><p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`, |
| 5482 | `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 reply</a>`, |
| 5483 | - `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`, |
| 5484 | `<room-live room="general"></room-live>`, |
| 5485 | `<room-compose room="general" pubkey="` + roomOwner + `">`, |
| 5486 | `<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>`, |
| 5487 | - `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent><nostr-name pubkey="` + roomAgent + `"`, `<small>member | agent</small>`, |
| 5488 | + `<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>`, |
| 5489 | `<room-action room="general" kind="9000">`, `<room-action room="general" kind="9002">`, `<room-action room="general" kind="9022">`, |
| 5490 | `<nav id="room-list"><a href="/rooms/general" aria-current="page">General`, |
| 5491 | `» <page-link url="http://relay.example/rooms/general" title="Copy the page address">rooms/general</page-link></span>`, |
| 5492 | @@ -170,7 +169,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) { |
| 5493 | } |
| 5494 | } |
| 5495 | 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) |
| 5496 | - if !(hello < chat && chat < thread && thread < reply) { |
| 5497 | + if !(hello < chat && chat < thread && reply == -1) { |
| 5498 | t.Fatalf("messages are not oldest first: %d %d %d %d", hello, chat, thread, reply) |
| 5499 | } |
| 5500 | if strings.Contains(body, `data-kind="7"`) || strings.Contains(body, "<script>alert") { |
| 5501 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 5502 | index abe2b53..aebe23a 100644 |
| 5503 | --- a/internal/webui/shell_test.go |
| 5504 | +++ b/internal/webui/shell_test.go |
| 5505 | @@ -309,7 +309,12 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) { |
| 5506 | } |
| 5507 | } |
| 5508 | |
| 5509 | -type connectionsBackend struct{ fakeBackend } |
| 5510 | +type connectionsBackend struct { |
| 5511 | + fakeBackend |
| 5512 | + sharePresence bool |
| 5513 | +} |
| 5514 | + |
| 5515 | +func (b *connectionsBackend) SharePresence(context.Context, string) bool { return b.sharePresence } |
| 5516 | |
| 5517 | func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) { |
| 5518 | if method == "connections" { |
| 5519 | @@ -319,19 +324,27 @@ func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.Ra |
| 5520 | } |
| 5521 | |
| 5522 | func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) { |
| 5523 | - backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 5524 | + backend := &connectionsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, sharePresence: true} |
| 5525 | app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }}) |
| 5526 | if err != nil { |
| 5527 | t.Fatal(err) |
| 5528 | } |
| 5529 | recorder := httptest.NewRecorder() |
| 5530 | - app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil)) |
| 5531 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/account", nil)) |
| 5532 | body := recorder.Body.String() |
| 5533 | 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"`} { |
| 5534 | if !strings.Contains(body, want) { |
| 5535 | t.Errorf("account page missing %q", want) |
| 5536 | } |
| 5537 | } |
| 5538 | + if !strings.Contains(body, `<input type="checkbox" name="share_presence" checked disabled>`) { |
| 5539 | + t.Error("account page did not show saved presence preference") |
| 5540 | + } |
| 5541 | + recorder = httptest.NewRecorder() |
| 5542 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil)) |
| 5543 | + if strings.Contains(recorder.Body.String(), "<relay-lists") { |
| 5544 | + t.Fatal("sign-in page still exposes account relay settings") |
| 5545 | + } |
| 5546 | if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `customElements.define("relay-lists"`) { |
| 5547 | t.Error("components script missing relay-lists") |
| 5548 | } |
| 5549 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 5550 | index 54916e6..c27b12d 100644 |
| 5551 | --- a/internal/webui/style.css |
| 5552 | +++ b/internal/webui/style.css |
| 5553 | @@ -324,7 +324,6 @@ room-message>div pre{margin:0 0 6px;padding:6px 8px;background:var(--rail);borde |
| 5554 | 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)} |
| 5555 | room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px} |
| 5556 | room-message>footer>*+*::before{content:" | "} |
| 5557 | -room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px} |
| 5558 | room-message[data-notice]>p{color:var(--muted);font-size:13px} |
| 5559 | room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:right}room-live:empty{display:none} |
| 5560 | 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)} |
| 5561 | @@ -522,3 +521,36 @@ direct-file article{padding:0;margin:0;border:0} |
| 5562 | direct-file button{display:block;margin:8px 0;padding:8px 12px;min-height:40px} |
| 5563 | direct-file output,direct-settings output{display:block;font-size:12px;color:var(--muted);margin-top:6px} |
| 5564 | direct-file pre{max-height:360px;overflow:auto;padding:10px;background:var(--rail);font:13px/1.5 var(--mono)} |
| 5565 | + |
| 5566 | +/* Chat activity stays compact; details and decisions share the room width. */ |
| 5567 | +chat-activity,chat-presence{display:block;min-width:0} |
| 5568 | +chat-activity:empty{display:none} |
| 5569 | +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} |
| 5570 | +chat-activity header,chat-activity summary{font:13px/1.6 var(--sans)} |
| 5571 | +chat-activity header small,chat-activity summary small{font:11px var(--mono);color:var(--muted) |