tinyrelaycommit

Commit 46bd1db112da

browse files at this commit | history

1diff --git a/docs/app.md b/docs/app.md
2index 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+
13diff --git a/docs/webmcp.md b/docs/webmcp.md
14index 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).
28diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
29index 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+}
88diff --git a/internal/daemon/profile_test.go b/internal/daemon/profile_test.go
89new file mode 100644
90index 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+}
149diff --git a/internal/webui/browse.html b/internal/webui/browse.html
150index 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}}
179diff --git a/internal/webui/components.js b/internal/webui/components.js
180index 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;
287diff --git a/internal/webui/page.html b/internal/webui/page.html
288index 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>
308diff --git a/internal/webui/profile_test.go b/internal/webui/profile_test.go
309new file mode 100644
310index 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+ `&#34;name&#34;:&#34;dami&#34;`,
356+ `<input name="name" maxlength="64" value="dami"`,
357+ `<textarea name="about" maxlength="2000">keeps &lt;cats&gt;</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+}
399diff --git a/internal/webui/style.css b/internal/webui/style.css
400index 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
419diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go
420index 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
432diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js
433index 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.",
445diff --git a/internal/webui/webui.go b/internal/webui/webui.go
446index 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":
521diff --git a/package.json b/package.json
522index 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",
535diff --git a/scripts/profile-form.test.mjs b/scripts/profile-form.test.mjs
536new file mode 100644
537index 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+});
616diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs
617index 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+

About

ownerc01503e8035f
visibilitypublic
head46bd1db112da

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs