| 1 | diff --git a/docs/app.md b/docs/app.md |
| 2 | index 290202d..0a7ac25 100644 |
| 3 | --- a/docs/app.md |
| 4 | +++ b/docs/app.md |
| 5 | @@ -28,3 +28,7 @@ People show by name wherever the relay knows one: room authors and members, ment |
| 6 | |
| 7 | Rebuild the bundle after changing `scripts/nostr-name-setup.mjs` or updating the package with `npm run build:nostr-name`. The package pulls its dependencies from the JSR registry, which `.npmrc` maps for the `@jsr` scope. |
| 8 | |
| 9 | +## Profile |
| 10 | + |
| 11 | +**Profile** in the home page's Actions, shown once you are signed in, edits the profile (kind 0) other people and apps see for your key: name, display name, about, picture, banner, website, NIP-05 address and lightning address. The form starts from the profile this relay holds, and fields it does not show are kept as they are. Saving signs the event with your connected signer, publishes it to this relay first, then to every relay in **Also publish to**, which starts out as the write relays from your relay list on this relay; each relay reports ok or failed under the form. The browser forgets your cached name at the same time, so pages show the new one on the next load. |
| 12 | + |
| 13 | diff --git a/docs/webmcp.md b/docs/webmcp.md |
| 14 | index 93e4261..b4deb61 100644 |
| 15 | --- a/docs/webmcp.md |
| 16 | +++ b/docs/webmcp.md |
| 17 | @@ -10,6 +10,10 @@ Rooms have their own tools. `tiny.list_rooms` lists the rooms you can see, `tiny |
| 18 | |
| 19 | Four tools cover the wiki. `tiny.list_wiki` lists pages with their shown version, version count and open merge requests; `q` searches titles and summaries and `author` prefers that key's versions. `tiny.read_wiki_page` returns one page with its content, links, every version, merge requests and redirects; `author` or `version` picks the version shown. `tiny.read_merge_request` returns one merge request with its answer, the proposed version and the destination author's current version. `tiny.open_wiki_page` opens the page list, one page, one version, the editor or the compare view for a merge request. The tools take a page name or title and normalize it the way the relay does. Publishing a version and answering a merge request stay signed actions on the page. |
| 20 | |
| 21 | +## Profile |
| 22 | + |
| 23 | +`tiny.read_profile` returns a profile as this relay holds it, with the write relays from that key's relay list; without `pubkey` it reads the signed-in person's own. Publishing a profile stays a signed action on the Profile page. |
| 24 | + |
| 25 | ## Approvals |
| 26 | |
| 27 | Three tools cover requests for a decision. `tiny.list_approvals` lists the requests addressed to the signed-in person with each one's asker, subject, expiry, state and answer, plus counts of open, answered and expired requests; `state` narrows the list to `open`, `answered`, `expired` or `all`. `tiny.read_approval` returns one request by event id with every reaction and reply from the people asked. `tiny.open_approvals` opens the Approvals page, focused on one request when `id` is given. Answering stays a manual step: the person approves, denies or replies with their own signer. To make a request, publish an event as described in [Asking a person](agents.md#asking-a-person). |
| 28 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 29 | index 69e1f7e..d6b3ab9 100644 |
| 30 | --- a/internal/daemon/client_browse.go |
| 31 | +++ b/internal/daemon/client_browse.go |
| 32 | @@ -31,10 +31,11 @@ type clientBrowseRequest struct { |
| 33 | State string `json:"state"` |
| 34 | Label string `json:"label"` |
| 35 | Agent string `json:"agent"` |
| 36 | + Pubkey string `json:"pubkey"` |
| 37 | } |
| 38 | |
| 39 | func clientBrowseMethod(method string) bool { |
| 40 | - return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method) |
| 41 | + return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method) |
| 42 | } |
| 43 | |
| 44 | func (t *Tenant) browseRead(ctx context.Context, actor string) error { |
| 45 | @@ -89,6 +90,8 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params |
| 46 | return t.browseStatus(ctx, actor) |
| 47 | case "browseagent": |
| 48 | return t.browseAgent(ctx, actor, q.Agent) |
| 49 | + case "browseprofile": |
| 50 | + return t.browseProfile(ctx, actor, q.Pubkey) |
| 51 | case "browseissues": |
| 52 | return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_ISSUE) |
| 53 | case "browsepulls": |
| 54 | @@ -432,3 +435,33 @@ func (t *Tenant) browseStatus(ctx context.Context, actor string) (any, error) { |
| 55 | result["errors"] = failures |
| 56 | return result, nil |
| 57 | } |
| 58 | + |
| 59 | +// browseProfile returns one key's profile as this relay holds it: the newest |
| 60 | +// kind 0 with its content parsed, and the write relays from the key's relay |
| 61 | +// list, so a profile editor can publish there too. The key defaults to the |
| 62 | +// caller's own. |
| 63 | +func (t *Tenant) browseProfile(ctx context.Context, actor, pubkey string) (any, error) { |
| 64 | + if pubkey == "" { |
| 65 | + pubkey = actor |
| 66 | + } |
| 67 | + if len(pubkey) != 64 || strings.Trim(pubkey, "0123456789abcdef") != "" { |
| 68 | + return nil, errors.New("invalid: public key required") |
| 69 | + } |
| 70 | + if err := t.browseRead(ctx, actor); err != nil { |
| 71 | + return nil, err |
| 72 | + } |
| 73 | + rows, err := t.store.Query(ctx, event.Filter{Authors: []string{pubkey}, Kinds: []int{event.KIND_PROFILE}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: browseSession(t, actor).PubKeys}, Limit: 1}) |
| 74 | + if err != nil { |
| 75 | + return nil, err |
| 76 | + } |
| 77 | + result := map[string]any{"pubkey": pubkey, "event": nil, "profile": map[string]any{}, "relays": localDirectory{store: t.store}.WriteRelays(pubkey)} |
| 78 | + if len(rows.Events) > 0 { |
| 79 | + profile := map[string]any{} |
| 80 | + if json.Unmarshal([]byte(rows.Events[0].Content), &profile) != nil { |
| 81 | + profile = map[string]any{} |
| 82 | + } |
| 83 | + result["event"] = rows.Events[0] |
| 84 | + result["profile"] = profile |
| 85 | + } |
| 86 | + return result, nil |
| 87 | +} |
| 88 | diff --git a/internal/daemon/profile_test.go b/internal/daemon/profile_test.go |
| 89 | new file mode 100644 |
| 90 | index 0000000..c4900ca |
| 91 | --- /dev/null |
| 92 | +++ b/internal/daemon/profile_test.go |
| 93 | @@ -0,0 +1,55 @@ |
| 94 | +package daemon |
| 95 | + |
| 96 | +import ( |
| 97 | + "encoding/json" |
| 98 | + "strings" |
| 99 | + "testing" |
| 100 | + |
| 101 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 102 | +) |
| 103 | + |
| 104 | +func TestBrowseProfileReturnsTheHeldProfileAndWriteRelays(t *testing.T) { |
| 105 | + h := newRoomHarness(t) |
| 106 | + h.must("owner", event.KIND_PROFILE, nil, `{"name":"dami","about":"relay keeper","lud16":"dami@wallet.example"}`) |
| 107 | + h.must("owner", 10002, [][]string{{"r", "wss://relay.one/"}, {"r", "wss://read.only", "read"}, {"r", "wss://relay.two", "write"}}, "") |
| 108 | + result, err := h.tenant.Execute(h.ctx, h.keys["owner"], "browseprofile", []json.RawMessage{rawJSON(map[string]any{})}) |
| 109 | + if err != nil { |
| 110 | + t.Fatal(err) |
| 111 | + } |
| 112 | + raw, _ := json.Marshal(result) |
| 113 | + var got struct { |
| 114 | + Pubkey string `json:"pubkey"` |
| 115 | + Profile map[string]any `json:"profile"` |
| 116 | + Relays []string `json:"relays"` |
| 117 | + Event *event.Event `json:"event"` |
| 118 | + } |
| 119 | + if err := json.Unmarshal(raw, &got); err != nil { |
| 120 | + t.Fatal(err) |
| 121 | + } |
| 122 | + if got.Pubkey != h.keys["owner"] || got.Profile["name"] != "dami" || got.Profile["lud16"] != "dami@wallet.example" || got.Event == nil || got.Event.Kind != 0 { |
| 123 | + t.Fatalf("own profile = %s", raw) |
| 124 | + } |
| 125 | + if strings.Join(got.Relays, " ") != "wss://relay.one/ wss://relay.two" { |
| 126 | + t.Fatalf("write relays = %v", got.Relays) |
| 127 | + } |
| 128 | + // Another member reads the owner's profile by key; an unknown key is empty. |
| 129 | + result, err = h.tenant.Execute(h.ctx, h.keys["alice"], "browseprofile", []json.RawMessage{rawJSON(map[string]any{"pubkey": h.keys["owner"]})}) |
| 130 | + if err != nil { |
| 131 | + t.Fatal(err) |
| 132 | + } |
| 133 | + raw, _ = json.Marshal(result) |
| 134 | + if !strings.Contains(string(raw), `"name":"dami"`) { |
| 135 | + t.Fatalf("profile by key = %s", raw) |
| 136 | + } |
| 137 | + result, err = h.tenant.Execute(h.ctx, h.keys["alice"], "browseprofile", []json.RawMessage{rawJSON(map[string]any{"pubkey": strings.Repeat("e", 64)})}) |
| 138 | + if err != nil { |
| 139 | + t.Fatal(err) |
| 140 | + } |
| 141 | + raw, _ = json.Marshal(result) |
| 142 | + if !strings.Contains(string(raw), `"event":null`) || !strings.Contains(string(raw), `"profile":{}`) { |
| 143 | + t.Fatalf("unknown profile = %s", raw) |
| 144 | + } |
| 145 | + if _, err := h.tenant.Execute(h.ctx, h.keys["alice"], "browseprofile", []json.RawMessage{rawJSON(map[string]any{"pubkey": "nope"})}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") { |
| 146 | + t.Fatalf("bad key: %v", err) |
| 147 | + } |
| 148 | +} |
| 149 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 150 | index cbb89cc..304a9de 100644 |
| 151 | --- a/internal/webui/browse.html |
| 152 | +++ b/internal/webui/browse.html |
| 153 | @@ -85,3 +85,25 @@ |
| 154 | {{else}}<p>No sites are published.</p> |
| 155 | {{end}}</section> |
| 156 | {{end}} |
| 157 | + |
| 158 | +{{define "profile"}}{{$data := asMap .Event}}{{$p := asMap (index $data "profile")}}<section> |
| 159 | +<h1>Profile</h1> |
| 160 | +<p>Your profile is a kind 0 event signed by your key. Saving publishes it to this relay first, then to the relays you list, so apps and people everywhere see the same name.</p> |
| 161 | +{{template "alert" .Error}} |
| 162 | +<profile-form pubkey="{{.Actor}}" existing="{{json (index $data "profile")}}"> |
| 163 | +<label>Name <input name="name" maxlength="64" value="{{str (index $p "name")}}" placeholder="dami"></label> |
| 164 | +<label>Display name <input name="display_name" maxlength="64" value="{{str (index $p "display_name")}}"></label> |
| 165 | +<label>About <textarea name="about" maxlength="2000">{{str (index $p "about")}}</textarea></label> |
| 166 | +<label>Picture <input name="picture" type="url" value="{{str (index $p "picture")}}" placeholder="https://"></label> |
| 167 | +<label>Banner <input name="banner" type="url" value="{{str (index $p "banner")}}" placeholder="https://"></label> |
| 168 | +<label>Website <input name="website" type="url" value="{{str (index $p "website")}}" placeholder="https://"></label> |
| 169 | +<label>NIP-05 <input name="nip05" value="{{str (index $p "nip05")}}" placeholder="you@example.com"></label> |
| 170 | +<label>Lightning address <input name="lud16" value="{{str (index $p "lud16")}}" placeholder="you@wallet.example"></label> |
| 171 | +<label>Also publish to <textarea name="relays" placeholder="wss://relay.example">{{range index $data "relays"}}{{.}} |
| 172 | +{{end}}</textarea></label> |
| 173 | +<button>Sign and publish</button> |
| 174 | +<noscript><p>Saving needs JavaScript and a connected signer. You can also publish a kind 0 event from any client with this relay in its list.</p></noscript> |
| 175 | +</profile-form> |
| 176 | +{{with index $data "event"}}{{$e := asMap .}}<p><small>Current profile published <time datetime="{{datetime (index $e "created_at")}}">{{when (index $e "created_at")}}</time>; fields this form does not show are kept as they are.</small></p>{{else}}<p><small>This relay holds no profile for your key yet.</small></p>{{end}} |
| 177 | +</section> |
| 178 | +{{end}} |
| 179 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 180 | index 49d7d74..61c2b71 100644 |
| 181 | --- a/internal/webui/components.js |
| 182 | +++ b/internal/webui/components.js |
| 183 | @@ -426,6 +426,77 @@ |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | + // ProfileForm publishes the signer's kind 0 profile: the fields shown here |
| 188 | + // are merged over the profile the relay already holds, so a field another |
| 189 | + // client set is kept. The event goes to this relay first, then to every |
| 190 | + // relay listed, and the browser's name cache forgets the old name. |
| 191 | + class ProfileForm extends FormElement { |
| 192 | + static content(values, existing) { |
| 193 | + let merged = {}; |
| 194 | + try { merged = typeof existing === "string" ? JSON.parse(existing || "{}") : {...(existing || {})}; } catch { merged = {}; } |
| 195 | + if (!merged || typeof merged !== "object" || Array.isArray(merged)) merged = {}; |
| 196 | + for (const key of ["name", "display_name", "about", "picture", "banner", "website", "nip05", "lud16"]) { |
| 197 | + if (!(key in values)) continue; |
| 198 | + const value = String(values[key] ?? "").trim(); |
| 199 | + if (value) merged[key] = value; else delete merged[key]; |
| 200 | + } |
| 201 | + for (const key of ["picture", "banner", "website"]) { |
| 202 | + if (merged[key] && !/^https?:\/\//.test(merged[key])) throw Error("Use an http or https link for " + key.replace("_", " ") + "."); |
| 203 | + } |
| 204 | + return merged; |
| 205 | + } |
| 206 | + |
| 207 | + static relays(text) { |
| 208 | + const seen = new Set(); |
| 209 | + for (const raw of String(text || "").split(/[\s,]+/)) { |
| 210 | + const url = tiny.util.relayURL(raw); |
| 211 | + if (url) seen.add(url); |
| 212 | + } |
| 213 | + if (seen.size > 12) throw Error("List at most 12 relays."); |
| 214 | + return [...seen]; |
| 215 | + } |
| 216 | + |
| 217 | + async submit(form) { |
| 218 | + if (!window.nostr?.signEvent) throw Error("Connect a signer first."); |
| 219 | + const value = name => form.elements[name]?.value ?? ""; |
| 220 | + const values = Object.fromEntries(["name", "display_name", "about", "picture", "banner", "website", "nip05", "lud16"].map(key => [key, value(key)])); |
| 221 | + const content = ProfileForm.content(values, this.getAttribute("existing") || "{}"); |
| 222 | + const relays = ProfileForm.relays(value("relays")); |
| 223 | + const unsigned = {kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify(content)}; |
| 224 | + const expected = JSON.stringify(unsigned); |
| 225 | + this.report("Signing…"); |
| 226 | + const event = await window.nostr.signEvent(JSON.parse(expected)); |
| 227 | + const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content}); |
| 228 | + if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event."); |
| 229 | + const pubkey = this.getAttribute("pubkey"); |
| 230 | + if (pubkey && event.pubkey !== pubkey) throw Error("The signer holds a different key than the one signed in."); |
| 231 | + const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"}); |
| 232 | + const result = await response.json().catch(() => ({})); |
| 233 | + if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the profile."); |
| 234 | + this.forget(event.pubkey); |
| 235 | + const lines = ["Published here."]; |
| 236 | + if (relays.length) { |
| 237 | + if (!window.NostrSigner?.SimplePool) throw Error("Published here; the signer bundle is still loading, so other relays were skipped."); |
| 238 | + const pool = new window.NostrSigner.SimplePool(); |
| 239 | + const outcomes = await Promise.allSettled(pool.publish(relays, event).map(p => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(Error("timed out")), 8000))]))); |
| 240 | + outcomes.forEach((outcome, index) => { lines.push(relays[index] + ": " + (outcome.status === "fulfilled" ? "ok" : "failed, " + (outcome.reason?.message || outcome.reason || "no answer"))); }); |
| 241 | + try { pool.close(relays); } catch {} |
| 242 | + } |
| 243 | + this.report(lines.join("\n")); |
| 244 | + } |
| 245 | + |
| 246 | + // forget drops the cached name so nostr-name shows the new one on the |
| 247 | + // next page. |
| 248 | + forget(pubkey) { |
| 249 | + try { |
| 250 | + const key = window.tinyNames?.cacheKey || "tiny:names:v1"; |
| 251 | + const cache = JSON.parse(localStorage.getItem(key) || "{}") || {}; |
| 252 | + delete cache[pubkey]; |
| 253 | + localStorage.setItem(key, JSON.stringify(cache)); |
| 254 | + } catch {} |
| 255 | + } |
| 256 | + } |
| 257 | + |
| 258 | // AgentGrant signs a kind 30392 agent grant: the agent's key, name, scope |
| 259 | // and expiry, signed by the owner or a moderator. The agent key is made |
| 260 | // here and its nsec shown once, or pasted as a public key. The secret never |
| 261 | @@ -1576,6 +1647,7 @@ |
| 262 | customElements.define("publish-list", PublishList); |
| 263 | customElements.define("nostr-compose", NostrCompose); |
| 264 | customElements.define("agent-grant", AgentGrant); |
| 265 | + customElements.define("profile-form", ProfileForm); |
| 266 | customElements.define("nostr-react", NostrReact); |
| 267 | customElements.define("approval-item", ApprovalItem); |
| 268 | customElements.define("wiki-compose", WikiCompose); |
| 269 | @@ -1613,7 +1685,7 @@ |
| 270 | if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null; |
| 271 | return root ? url.pathname.slice(root.length) || "/" : url.pathname; |
| 272 | }; |
| 273 | - const allowedRoute = path => /^(?:\/(?:inbox|approvals|outbox|search|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)\/.+|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || ""); |
| 274 | + const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|outbox|search|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)\/.+|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || ""); |
| 275 | let navigationSerial = 0, activeAbort; |
| 276 | const streams = new Set(); |
| 277 | const closeStreams = () => { |
| 278 | @@ -1628,7 +1700,7 @@ |
| 279 | document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg)); |
| 280 | window.addEventListener("pagehide", closeStreams); |
| 281 | const repairComponents = () => { |
| 282 | - document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => { |
| 283 | + document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => { |
| 284 | if (node.form?.isConnected) return; |
| 285 | node.form = null; |
| 286 | node.output = null; |
| 287 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 288 | index 55f6981..61d4133 100644 |
| 289 | --- a/internal/webui/page.html |
| 290 | +++ b/internal/webui/page.html |
| 291 | @@ -56,6 +56,7 @@ |
| 292 | {{else if eq .Tab "wikipage"}}{{template "wikipage" .}} |
| 293 | {{else if eq .Tab "file"}}{{template "file" .}} |
| 294 | {{else if eq .Tab "approvals"}}{{template "approvals" .}} |
| 295 | +{{else if eq .Tab "profile"}}{{template "profile" .}} |
| 296 | {{else if eq .Tab "rooms"}}{{template "rooms" .}} |
| 297 | {{else if eq .Tab "room"}}{{template "room" .}} |
| 298 | {{else if eq .Tab "thread"}}{{template "thread" .}} |
| 299 | @@ -113,7 +114,7 @@ |
| 300 | {{end}}{{end}} |
| 301 | |
| 302 | {{define "panel-relay"}}<section><h4>About</h4><table><tr><th>identity</th><td>{{template "nostrKey" .Identity}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>search</th><td>{{.Policy.Features.Search}}</td></tr>{{if .Policy.Features.Grasp}}<tr><th>git</th><td>GRASP-01</td></tr>{{end}}{{if .Policy.Features.Files}}<tr><th>files</th><td>Blossom</td></tr>{{end}}</table></section> |
| 303 | -<section><h4>Actions</h4><ul>{{if not .Actor}}<li><a href="/signin">sign in</a></li>{{end}}<li><a href="/manage/people">manage</a></li><li><a href="/.well-known/nostr.json">NIP-05 names</a></li>{{if .Policy.Features.Pages}}<li><a href="/feed.xml">Atom feed</a></li>{{end}}<li><a href="/terms">terms</a></li></ul></section> |
| 304 | +<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/profile">profile</a></li>{{else}}<li><a href="/signin">sign in</a></li>{{end}}<li><a href="/.well-known/nostr.json">NIP-05 names</a></li>{{if .Policy.Features.Pages}}<li><a href="/feed.xml">Atom feed</a></li>{{end}}<li><a href="/terms">terms</a></li></ul></section> |
| 305 | <section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}} |
| 306 | |
| 307 | {{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> |
| 308 | diff --git a/internal/webui/profile_test.go b/internal/webui/profile_test.go |
| 309 | new file mode 100644 |
| 310 | index 0000000..f445dcd |
| 311 | --- /dev/null |
| 312 | +++ b/internal/webui/profile_test.go |
| 313 | @@ -0,0 +1,85 @@ |
| 314 | +package webui |
| 315 | + |
| 316 | +import ( |
| 317 | + "context" |
| 318 | + "encoding/json" |
| 319 | + "errors" |
| 320 | + "net/http" |
| 321 | + "net/http/httptest" |
| 322 | + "strings" |
| 323 | + "testing" |
| 324 | + |
| 325 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 326 | +) |
| 327 | + |
| 328 | +type profileBackend struct { |
| 329 | + fakeBackend |
| 330 | + params json.RawMessage |
| 331 | +} |
| 332 | + |
| 333 | +func (b *profileBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 334 | + if method != "browseprofile" { |
| 335 | + return nil, nil |
| 336 | + } |
| 337 | + if actor == "" { |
| 338 | + return nil, errors.New("auth-required: sign in") |
| 339 | + } |
| 340 | + b.params = params[0] |
| 341 | + return map[string]any{"pubkey": actor, "event": map[string]any{"kind": 0, "created_at": 1788900000}, "profile": map[string]any{"name": "dami", "about": "keeps <cats>", "lud16": "dami@wallet.example", "banner": ""}, "relays": []any{"wss://relay.one", "wss://relay.two"}}, nil |
| 342 | +} |
| 343 | + |
| 344 | +func TestProfilePagePrefillsTheFormAndOffersItFromHome(t *testing.T) { |
| 345 | + backend := &profileBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 346 | + app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }}) |
| 347 | + if err != nil { |
| 348 | + t.Fatal(err) |
| 349 | + } |
| 350 | + recorder := httptest.NewRecorder() |
| 351 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/profile", nil)) |
| 352 | + body := recorder.Body.String() |
| 353 | + for _, marker := range []string{ |
| 354 | + `<profile-form pubkey="` + backend.policy.Owner + `" existing="{`, |
| 355 | + `"name":"dami"`, |
| 356 | + `<input name="name" maxlength="64" value="dami"`, |
| 357 | + `<textarea name="about" maxlength="2000">keeps <cats></textarea>`, |
| 358 | + `<input name="lud16" value="dami@wallet.example"`, |
| 359 | + "wss://relay.one\nwss://relay.two\n</textarea>", |
| 360 | + `<button>Sign and publish</button>`, |
| 361 | + "Current profile published <time", |
| 362 | + } { |
| 363 | + if !strings.Contains(body, marker) { |
| 364 | + t.Errorf("profile page missing %q", marker) |
| 365 | + } |
| 366 | + } |
| 367 | + if strings.Contains(body, "class=") { |
| 368 | + t.Fatal("profile page carries a class attribute") |
| 369 | + } |
| 370 | + if !strings.Contains(string(backend.params), `"pubkey":"`+backend.policy.Owner+`"`) { |
| 371 | + t.Fatalf("profile query params = %s", backend.params) |
| 372 | + } |
| 373 | + recorder = httptest.NewRecorder() |
| 374 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) |
| 375 | + home := recorder.Body.String() |
| 376 | + if !strings.Contains(home, `<li><a href="/profile">profile</a></li>`) || strings.Contains(home, `<a href="/manage/people">manage</a>`) { |
| 377 | + t.Fatal("home panel does not offer the profile action, or still offers manage") |
| 378 | + } |
| 379 | +} |
| 380 | + |
| 381 | +func TestProfilePageAsksGuestsToSignIn(t *testing.T) { |
| 382 | + backend := &profileBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 383 | + app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }}) |
| 384 | + if err != nil { |
| 385 | + t.Fatal(err) |
| 386 | + } |
| 387 | + recorder := httptest.NewRecorder() |
| 388 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/profile", nil)) |
| 389 | + body := recorder.Body.String() |
| 390 | + if recorder.Code != http.StatusOK || !strings.Contains(body, "Sign in to edit your profile.") || strings.Contains(body, "<profile-form") { |
| 391 | + t.Fatalf("guest profile page: %d %s", recorder.Code, body) |
| 392 | + } |
| 393 | + recorder = httptest.NewRecorder() |
| 394 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) |
| 395 | + if home := recorder.Body.String(); !strings.Contains(home, `<li><a href="/signin">sign in</a></li>`) || strings.Contains(home, `href="/profile"`) { |
| 396 | + t.Fatal("guest home panel should offer sign in, not the profile") |
| 397 | + } |
| 398 | +} |
| 399 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 400 | index c570e4b..9f5ee4a 100644 |
| 401 | --- a/internal/webui/style.css |
| 402 | +++ b/internal/webui/style.css |
| 403 | @@ -146,11 +146,11 @@ agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit} |
| 404 | agent-callback rpc-form{display:inline-block;border:0;padding:0;margin:0 0 0 6px;vertical-align:middle}agent-callback rpc-form output:empty{display:none}agent-callback button{font-size:12px;padding:1px 6px} |
| 405 | #rows:has(td[data-state]){table-layout:fixed}#rows:has(td[data-state]) td:nth-child(3){width:110px}#rows:has(td[data-state]) td:last-child{width:150px} |
| 406 | [data-state=active],#rows td[data-state=active]{color:var(--accent)}[data-state=paused],#rows td[data-state=paused]{color:var(--bark)}[data-state=revoked],#rows td[data-state=revoked]{color:var(--alert)} |
| 407 | -agent-grant{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px} |
| 408 | -agent-grant label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)} |
| 409 | -agent-grant label input,agent-grant label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px} |
| 410 | +agent-grant,profile-form{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px} |
| 411 | +agent-grant label,profile-form label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)} |
| 412 | +agent-grant label input,agent-grant label select,profile-form label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}profile-form label:has(textarea){display:block}profile-form textarea{display:block;width:100%;max-width:520px;min-height:3em;font:13px var(--mono)}profile-form output{white-space:pre-wrap} |
| 413 | agent-grant label:has(textarea),agent-grant label[data-secret]{display:block}agent-grant label[data-secret] input{width:100%}agent-grant label[hidden]{display:none} |
| 414 | -agent-grant output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}agent-grant output[data-error]{color:var(--alert)} |
| 415 | +agent-grant output,profile-form output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}agent-grant output[data-error],profile-form output[data-error]{color:var(--alert)} |
| 416 | #panel rpc-form{border:0;padding:4px 0}#panel rpc-form output:empty{display:none} |
| 417 | connect-card{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}connect-card h3{margin:0 0 4px;border:0;font-size:15px}connect-card h3 small{font:12px var(--mono);color:var(--muted)}connect-card p{margin:0 0 6px;color:var(--muted)}connect-card label{display:inline-block;font:13px var(--sans);color:var(--muted);margin:0 10px 6px 0}connect-card label input{display:block;font:13px var(--mono);min-width:200px}connect-card a,connect-card button{margin-right:10px;font-size:13px}connect-card output{display:block;font:12px var(--mono);color:var(--muted);overflow-wrap:anywhere}connect-card details img{display:block;margin:6px 0;background:#fff;padding:6px;border:1px solid var(--line)} |
| 418 | |
| 419 | diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go |
| 420 | index 124f607..05deb70 100644 |
| 421 | --- a/internal/webui/webmcp.go |
| 422 | +++ b/internal/webui/webmcp.go |
| 423 | @@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) { |
| 424 | return |
| 425 | } |
| 426 | } |
| 427 | - allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true, "browseapprovals": true, "browseapproval": true, "browsejobs": true, "browsejob": true} |
| 428 | + allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true, "browseagent": true, "browseprofile": true, "browseapprovals": true, "browseapproval": true, "browsejobs": true, "browsejob": true} |
| 429 | if !allowed[method] { |
| 430 | writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"}) |
| 431 | return |
| 432 | diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js |
| 433 | index eb944d2..921568f 100644 |
| 434 | --- a/internal/webui/webmcp.js |
| 435 | +++ b/internal/webui/webmcp.js |
| 436 | @@ -102,6 +102,8 @@ |
| 437 | register("tiny.list_approvals", "List requests for a decision addressed to the signed-in person, with each request's asker, subject, expiry, state and answer, plus counts of open, answered and expired requests. Answering is the person's own signed action.", |
| 438 | object({cursor: text, limit, state: {type: "string", enum: ["open", "answered", "expired", "all"]}}), reads, |
| 439 | (input, signal) => query("browseapprovals", input, signal)); |
| 440 | + register("tiny.read_profile", "Read a profile (kind 0) as this relay holds it, with the write relays from that key's relay list. Omit pubkey for the signed-in person's own profile.", |
| 441 | + object({pubkey}), reads, (input, signal) => query("browseprofile", input, signal)); |
| 442 | register("tiny.read_approval", "Read one request for a decision by event id with every reaction and reply from the people asked.", |
| 443 | object({id: hash}, ["id"]), reads, (input, signal) => query("browseapproval", input, signal)); |
| 444 | register("tiny.list_jobs", "List long task requests (NIP-90 job requests) visible to your account, each with its newest feedback status and its result when one exists. state narrows the list to open, done or all; mine lists only your own requests.", |
| 445 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 446 | index be9e8ae..bccde62 100644 |
| 447 | --- a/internal/webui/webui.go |
| 448 | +++ b/internal/webui/webui.go |
| 449 | @@ -164,7 +164,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 450 | http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently) |
| 451 | return |
| 452 | } |
| 453 | - if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/wiki" || strings.HasPrefix(request.URL.Path, "/wiki/") || request.URL.Path == "/manage/health" || roomRoute(request.URL.Path).tab != "") { |
| 454 | + if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/profile" || request.URL.Path == "/wiki" || strings.HasPrefix(request.URL.Path, "/wiki/") || request.URL.Path == "/manage/health" || roomRoute(request.URL.Path).tab != "") { |
| 455 | a.browse(writer, request) |
| 456 | return |
| 457 | } |
| 458 | @@ -530,6 +530,8 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) { |
| 459 | method = "browsefile" |
| 460 | case "/approvals": |
| 461 | method = "browseapprovals" |
| 462 | + case "/profile": |
| 463 | + method = "browseprofile" |
| 464 | case "/wiki": |
| 465 | method = "browsewiki" |
| 466 | case "/manage/health": |
| 467 | @@ -592,6 +594,8 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) { |
| 468 | if query["hash"] == "" { |
| 469 | query["hash"] = request.URL.Query().Get("sha") |
| 470 | } |
| 471 | + case "browseprofile": |
| 472 | + query = map[string]any{"pubkey": actor} |
| 473 | case "browseapprovals": |
| 474 | state := request.URL.Query().Get("state") |
| 475 | if state == "" { |
| 476 | @@ -659,7 +663,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) { |
| 477 | } |
| 478 | |
| 479 | func browseTitle(path string, query url.Values, result any, slug string) string { |
| 480 | - label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki", "/rooms": "Rooms"}[path] |
| 481 | + label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/profile": "Profile", "/wiki": "Wiki", "/rooms": "Rooms"}[path] |
| 482 | if strings.HasPrefix(path, "/wiki/") { |
| 483 | label = plainString(valueMap(result)["title"]) |
| 484 | if label == "" { |
| 485 | @@ -744,6 +748,8 @@ func browseTab(path string) string { |
| 486 | return "file" |
| 487 | case "/approvals": |
| 488 | return "approvals" |
| 489 | + case "/profile": |
| 490 | + return "profile" |
| 491 | default: |
| 492 | return "health" |
| 493 | } |
| 494 | @@ -787,10 +793,14 @@ func (a *App) render(writer http.ResponseWriter, request *http.Request, data Pag |
| 495 | } |
| 496 | data.Path = strings.TrimPrefix(request.URL.Path, data.Base) |
| 497 | a.privatePageData(&data, request, actor) |
| 498 | - if !data.Private && data.Actor == "" && railKind(data.Tab) == "manage" { |
| 499 | - // Management pages are for signed-in people only. Guests see the |
| 500 | - // sign-in page at the same address and come back after signing in. |
| 501 | + if !data.Private && data.Actor == "" && (railKind(data.Tab) == "manage" || data.Tab == "profile") { |
| 502 | + // Management pages and the profile editor are for signed-in people |
| 503 | + // only. Guests see the sign-in page at the same address and come back |
| 504 | + // after signing in. |
| 505 | data.Tab, data.Notice = "signin", "Sign in to manage this relay." |
| 506 | + if data.Path == "/profile" { |
| 507 | + data.Notice = "Sign in to edit your profile." |
| 508 | + } |
| 509 | data.Event, data.Feed, data.Error = nil, nil, "" |
| 510 | } |
| 511 | writer.Header().Set("Cache-Control", "private, no-store") |
| 512 | @@ -1235,6 +1245,8 @@ func tabForPath(path string) string { |
| 513 | return "file" |
| 514 | case "approvals": |
| 515 | return "approvals" |
| 516 | + case "profile": |
| 517 | + return "profile" |
| 518 | case "signin", "sites": |
| 519 | return path |
| 520 | case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner": |
| 521 | diff --git a/package.json b/package.json |
| 522 | index ca84a1d..e8a201f 100644 |
| 523 | --- a/package.json |
| 524 | +++ b/package.json |
| 525 | @@ -13,7 +13,8 @@ |
| 526 | "test:agents": "node --test scripts/agent-grant.test.mjs", |
| 527 | "test:approvals": "node --test scripts/approvals.test.mjs", |
| 528 | "test:wiki": "node --test scripts/wiki-compose.test.mjs", |
| 529 | - "test:rooms": "node --test scripts/rooms.test.mjs" |
| 530 | + "test:rooms": "node --test scripts/rooms.test.mjs", |
| 531 | + "test:profile": "node --test scripts/profile-form.test.mjs" |
| 532 | }, |
| 533 | "devDependencies": { |
| 534 | "@noble/hashes": "2.4.0", |
| 535 | diff --git a/scripts/profile-form.test.mjs b/scripts/profile-form.test.mjs |
| 536 | new file mode 100644 |
| 537 | index 0000000..19bd640 |
| 538 | --- /dev/null |
| 539 | +++ b/scripts/profile-form.test.mjs |
| 540 | @@ -0,0 +1,75 @@ |
| 541 | +// The profile form merges the fields it shows over the profile the relay |
| 542 | +// holds, signs a kind 0, publishes it here and then to the listed relays. |
| 543 | +import test from "node:test"; |
| 544 | +import assert from "node:assert/strict"; |
| 545 | +import fs from "node:fs"; |
| 546 | +import vm from "node:vm"; |
| 547 | +import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure"; |
| 548 | + |
| 549 | +const source = fs.readFileSync("internal/webui/components.js", "utf8"); |
| 550 | +const formSource = source.slice(source.indexOf(" // ProfileForm publishes"), source.indexOf(" // AgentGrant signs")); |
| 551 | +const plain = value => JSON.parse(JSON.stringify(value)); |
| 552 | + |
| 553 | +function setup({attributes = {}, result, pool, noSigner = false, otherKey = false} = {}) { |
| 554 | + const secret = generateSecretKey(), pubkey = getPublicKey(secret); |
| 555 | + const sent = [], published = [], reports = []; |
| 556 | + class FormElement { |
| 557 | + getAttribute(name) { return attributes[name] ?? (name === "pubkey" ? (otherKey ? "f".repeat(64) : pubkey) : null); } |
| 558 | + report(text, error) { reports.push({text, error: Boolean(error)}); } |
| 559 | + } |
| 560 | + const store = {}; |
| 561 | + const localStorage = {getItem: key => store[key] ?? null, setItem: (key, value) => { store[key] = value; }}; |
| 562 | + const window = { |
| 563 | + nostr: noSigner ? undefined : {signEvent: async event => finalizeEvent(structuredClone(event), secret)}, |
| 564 | + NostrSigner: {verifyEvent, SimplePool: class { publish(relays, event) { published.push({relays, event}); return relays.map(relay => (pool?.[relay] === "fail" ? Promise.reject(Error("closed")) : Promise.resolve("ok"))); } close() {} }}, |
| 565 | + tinyNames: {cacheKey: "tiny:names:v1"} |
| 566 | + }; |
| 567 | + const tiny = { |
| 568 | + util: {relayURL: value => (/^wss?:\/\/[a-z0-9.-]+(?::\d+)?\/?$/i.test(String(value).trim()) ? String(value).trim().replace(/\/$/, "") : null)}, |
| 569 | + signedFetch: async (path, method, body) => { sent.push({path, method, event: JSON.parse(body)}); return Response.json(result ?? {accepted: true}); } |
| 570 | + }; |
| 571 | + const Constructor = vm.runInNewContext(`${formSource}\nProfileForm`, {FormElement, window, tiny, localStorage, JSON, Date, Error, Promise, setTimeout, Object, Array, String}); |
| 572 | + const form = values => ({elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}]))}); |
| 573 | + return {Constructor, component: new Constructor(), form, sent, published, reports, pubkey, store}; |
| 574 | +} |
| 575 | + |
| 576 | +test("content merges shown fields over the held profile and validates links", () => { |
| 577 | + const {Constructor} = setup(); |
| 578 | + const merged = Constructor.content({name: " dami ", display_name: "", about: "hi", picture: "https://x/p.png"}, JSON.stringify({name: "old", display_name: "Old Name", website: "https://kept.example", custom: {deep: true}})); |
| 579 | + assert.deepEqual(plain(merged), {name: "dami", about: "hi", picture: "https://x/p.png", website: "https://kept.example", custom: {deep: true}}); |
| 580 | + assert.deepEqual(plain(Constructor.content({name: "x"}, "not json")), {name: "x"}); |
| 581 | + assert.throws(() => Constructor.content({picture: "javascript:alert(1)"}, "{}"), /http or https/); |
| 582 | +}); |
| 583 | + |
| 584 | +test("relays parse from any separator, dedupe and cap at twelve", () => { |
| 585 | + const {Constructor} = setup(); |
| 586 | + assert.deepEqual(plain(Constructor.relays("wss://a.example/\nwss://b.example, wss://a.example ftp://no.example")), ["wss://a.example", "wss://b.example"]); |
| 587 | + assert.throws(() => Constructor.relays(Array.from({length: 13}, (_, i) => "wss://r" + i + ".example").join(" ")), /at most 12/); |
| 588 | +}); |
| 589 | + |
| 590 | +test("submit publishes here first, then to the listed relays, and forgets the cached name", async () => { |
| 591 | + const s = setup({pool: {"wss://b.example": "fail"}}); |
| 592 | + s.store["tiny:names:v1"] = JSON.stringify({[s.pubkey]: {event: null, at: 1}, other: {at: 2}}); |
| 593 | + await s.component.submit(s.form({name: "dami", about: "", relays: "wss://a.example\nwss://b.example"})); |
| 594 | + assert.equal(s.sent.length, 1); |
| 595 | + assert.equal(s.sent[0].path, "/events"); |
| 596 | + const event = s.sent[0].event; |
| 597 | + assert.equal(event.kind, 0); |
| 598 | + assert.deepEqual(plain(JSON.parse(event.content)), {name: "dami"}); |
| 599 | + assert.equal(verifyEvent(event), true); |
| 600 | + assert.deepEqual(plain(s.published[0].relays), ["wss://a.example", "wss://b.example"]); |
| 601 | + assert.equal(s.published[0].event.id, event.id); |
| 602 | + const last = s.reports.at(-1).text.split("\n"); |
| 603 | + assert.deepEqual(last, ["Published here.", "wss://a.example: ok", "wss://b.example: failed, closed"]); |
| 604 | + assert.deepEqual(Object.keys(JSON.parse(s.store["tiny:names:v1"])), ["other"]); |
| 605 | +}); |
| 606 | + |
| 607 | +test("submit refuses without a signer, with another key, or when the relay rejects", async () => { |
| 608 | + await assert.rejects(setup({noSigner: true}).component.submit(setup().form({name: "x"})), /Connect a signer/); |
| 609 | + const other = setup({otherKey: true}); |
| 610 | + await assert.rejects(other.component.submit(other.form({name: "x"})), /different key/); |
| 611 | + assert.equal(other.sent.length, 0); |
| 612 | + const refused = setup({result: {accepted: false, message: "blocked: no"}}); |
| 613 | + await assert.rejects(refused.component.submit(refused.form({name: "x"})), /blocked: no/); |
| 614 | + assert.equal(refused.published.length, 0); |
| 615 | +}); |
| 616 | diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs |
| 617 | index 148e801..2461c3c 100644 |
| 618 | --- a/scripts/webmcp.test.mjs |
| 619 | +++ b/scripts/webmcp.test.mjs |
| 620 | @@ -338,3 +338,17 @@ test("room tools read rooms and threads, open rooms and post signed messages", a |
| 621 | await assert.rejects(post.execute({room: "build", text: "again"}), /members only/); |
| 622 | await assert.rejects(post.execute({room: "build", text: " "}), /Enter a message/); |
| 623 | }); |
| 624 | + |
| 625 | +test("the profile tool reads through the session and defaults to the signed-in key", async () => { |
| 626 | + const requests = []; |
| 627 | + const {tools} = await browser({path: "/r/work/profile", fetch: async url => { requests.push(new URL(url, "https://tiny.example")); return new Response(JSON.stringify({pubkey: "a".repeat(64), profile: {name: "dami"}, relays: []})); }}); |
| 628 | + const tool = tools.get("tiny.read_profile"); |
| 629 | + assert.equal(tool.annotations.readOnlyHint, true); |
| 630 | + const own = await tool.execute({}); |
| 631 | + assert.equal(requests[0].searchParams.get("method"), "browseprofile"); |
| 632 | + assert.deepEqual(JSON.parse(requests[0].searchParams.get("params")), [{}]); |
| 633 | + assert.equal(own.structuredContent.result.profile.name, "dami"); |
| 634 | + await tool.execute({pubkey: "b".repeat(64)}); |
| 635 | + assert.deepEqual(JSON.parse(requests[1].searchParams.get("params")), [{pubkey: "b".repeat(64)}]); |
| 636 | +}); |
| 637 | + |