clone

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

edit and publish your profile from the home page

Dami committed 46bd1db
commit
46bd1db112dadf7d1e3efe0c960a4206aaeab5c8
parent
126290ef34205bc27cc03d98df553fcd0405fbb7
author
Dami <dami.etoile@gmail.com>
15 files changed, +395 -15
Mdocs/app.md4+
Mdocs/webmcp.md4+
Minternal/daemon/client_browse.go35++++++++++++-
Ainternal/daemon/profile_test.go55+++++++++++++++++++
Minternal/webui/browse.html22++++++++
Minternal/webui/components.js76++++++++++++++++++++++++++-
Minternal/webui/page.html3+-
Ainternal/webui/profile_test.go85++++++++++++++++++++++++++++++
Minternal/webui/style.css8+-
Minternal/webui/webmcp.go2+-
Minternal/webui/webmcp.js2+
Minternal/webui/webui.go22++++++--
Mpackage.json3+-
Ascripts/profile-form.test.mjs75++++++++++++++++++++++++++
Mscripts/webmcp.test.mjs14+++++
M docs/app.md +4 -0
M docs/webmcp.md +4 -0
@@ -10,6 +10,10 @@ Rooms have their own tools. `tiny.list_rooms` lists the rooms you can see, `tiny
1010 1111 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.1212 13+## Profile14+15+`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.16+1317 ## Approvals1418 1519 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).
M internal/daemon/client_browse.go +34 -1
@@ -31,10 +31,11 @@ type clientBrowseRequest struct {
3131 State string `json:"state"`3232 Label string `json:"label"`3333 Agent string `json:"agent"`34+ Pubkey string `json:"pubkey"`3435 }3536 3637 func clientBrowseMethod(method string) bool {37- return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method)38+ return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)3839 }3940 4041 func (t *Tenant) browseRead(ctx context.Context, actor string) error {
@@ -89,6 +90,8 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
8990 return t.browseStatus(ctx, actor)9091 case "browseagent":9192 return t.browseAgent(ctx, actor, q.Agent)93+ case "browseprofile":94+ return t.browseProfile(ctx, actor, q.Pubkey)9295 case "browseissues":9396 return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_ISSUE)9497 case "browsepulls":
@@ -432,3 +435,33 @@ func (t *Tenant) browseStatus(ctx context.Context, actor string) (any, error) {
432435 result["errors"] = failures433436 return result, nil434437 }438+439+// browseProfile returns one key's profile as this relay holds it: the newest440+// kind 0 with its content parsed, and the write relays from the key's relay441+// list, so a profile editor can publish there too. The key defaults to the442+// caller's own.443+func (t *Tenant) browseProfile(ctx context.Context, actor, pubkey string) (any, error) {444+ if pubkey == "" {445+ pubkey = actor446+ }447+ if len(pubkey) != 64 || strings.Trim(pubkey, "0123456789abcdef") != "" {448+ return nil, errors.New("invalid: public key required")449+ }450+ if err := t.browseRead(ctx, actor); err != nil {451+ return nil, err452+ }453+ 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})454+ if err != nil {455+ return nil, err456+ }457+ result := map[string]any{"pubkey": pubkey, "event": nil, "profile": map[string]any{}, "relays": localDirectory{store: t.store}.WriteRelays(pubkey)}458+ if len(rows.Events) > 0 {459+ profile := map[string]any{}460+ if json.Unmarshal([]byte(rows.Events[0].Content), &profile) != nil {461+ profile = map[string]any{}462+ }463+ result["event"] = rows.Events[0]464+ result["profile"] = profile465+ }466+ return result, nil467+}
A internal/daemon/profile_test.go +55 -0
@@ -0,0 +1,55 @@
1+package daemon2+3+import (4+ "encoding/json"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+)10+11+func TestBrowseProfileReturnsTheHeldProfileAndWriteRelays(t *testing.T) {12+ h := newRoomHarness(t)13+ h.must("owner", event.KIND_PROFILE, nil, `{"name":"dami","about":"relay keeper","lud16":"dami@wallet.example"}`)14+ h.must("owner", 10002, [][]string{{"r", "wss://relay.one/"}, {"r", "wss://read.only", "read"}, {"r", "wss://relay.two", "write"}}, "")15+ result, err := h.tenant.Execute(h.ctx, h.keys["owner"], "browseprofile", []json.RawMessage{rawJSON(map[string]any{})})16+ if err != nil {17+ t.Fatal(err)18+ }19+ raw, _ := json.Marshal(result)20+ var got struct {21+ Pubkey string `json:"pubkey"`22+ Profile map[string]any `json:"profile"`23+ Relays []string `json:"relays"`24+ Event *event.Event `json:"event"`25+ }26+ if err := json.Unmarshal(raw, &got); err != nil {27+ t.Fatal(err)28+ }29+ if got.Pubkey != h.keys["owner"] || got.Profile["name"] != "dami" || got.Profile["lud16"] != "dami@wallet.example" || got.Event == nil || got.Event.Kind != 0 {30+ t.Fatalf("own profile = %s", raw)31+ }32+ if strings.Join(got.Relays, " ") != "wss://relay.one/ wss://relay.two" {33+ t.Fatalf("write relays = %v", got.Relays)34+ }35+ // Another member reads the owner's profile by key; an unknown key is empty.36+ result, err = h.tenant.Execute(h.ctx, h.keys["alice"], "browseprofile", []json.RawMessage{rawJSON(map[string]any{"pubkey": h.keys["owner"]})})37+ if err != nil {38+ t.Fatal(err)39+ }40+ raw, _ = json.Marshal(result)41+ if !strings.Contains(string(raw), `"name":"dami"`) {42+ t.Fatalf("profile by key = %s", raw)43+ }44+ result, err = h.tenant.Execute(h.ctx, h.keys["alice"], "browseprofile", []json.RawMessage{rawJSON(map[string]any{"pubkey": strings.Repeat("e", 64)})})45+ if err != nil {46+ t.Fatal(err)47+ }48+ raw, _ = json.Marshal(result)49+ if !strings.Contains(string(raw), `"event":null`) || !strings.Contains(string(raw), `"profile":{}`) {50+ t.Fatalf("unknown profile = %s", raw)51+ }52+ 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:") {53+ t.Fatalf("bad key: %v", err)54+ }55+}
M internal/webui/browse.html +22 -0
@@ -85,3 +85,25 @@
8585 {{else}}<p>No sites are published.</p>8686 {{end}}</section>8787 {{end}}88+89+{{define "profile"}}{{$data := asMap .Event}}{{$p := asMap (index $data "profile")}}<section>90+<h1>Profile</h1>91+<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>92+{{template "alert" .Error}}93+<profile-form pubkey="{{.Actor}}" existing="{{json (index $data "profile")}}">94+<label>Name <input name="name" maxlength="64" value="{{str (index $p "name")}}" placeholder="dami"></label>95+<label>Display name <input name="display_name" maxlength="64" value="{{str (index $p "display_name")}}"></label>96+<label>About <textarea name="about" maxlength="2000">{{str (index $p "about")}}</textarea></label>97+<label>Picture <input name="picture" type="url" value="{{str (index $p "picture")}}" placeholder="https://"></label>98+<label>Banner <input name="banner" type="url" value="{{str (index $p "banner")}}" placeholder="https://"></label>99+<label>Website <input name="website" type="url" value="{{str (index $p "website")}}" placeholder="https://"></label>100+<label>NIP-05 <input name="nip05" value="{{str (index $p "nip05")}}" placeholder="you@example.com"></label>101+<label>Lightning address <input name="lud16" value="{{str (index $p "lud16")}}" placeholder="you@wallet.example"></label>102+<label>Also publish to <textarea name="relays" placeholder="wss://relay.example">{{range index $data "relays"}}{{.}}103+{{end}}</textarea></label>104+<button>Sign and publish</button>105+<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>106+</profile-form>107+{{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}}108+</section>109+{{end}}
M internal/webui/components.js +74 -2
@@ -426,6 +426,77 @@
426426 }427427 }428428 429+ // ProfileForm publishes the signer's kind 0 profile: the fields shown here430+ // are merged over the profile the relay already holds, so a field another431+ // client set is kept. The event goes to this relay first, then to every432+ // relay listed, and the browser's name cache forgets the old name.433+ class ProfileForm extends FormElement {434+ static content(values, existing) {435+ let merged = {};436+ try { merged = typeof existing === "string" ? JSON.parse(existing || "{}") : {...(existing || {})}; } catch { merged = {}; }437+ if (!merged || typeof merged !== "object" || Array.isArray(merged)) merged = {};438+ for (const key of ["name", "display_name", "about", "picture", "banner", "website", "nip05", "lud16"]) {439+ if (!(key in values)) continue;440+ const value = String(values[key] ?? "").trim();441+ if (value) merged[key] = value; else delete merged[key];442+ }443+ for (const key of ["picture", "banner", "website"]) {444+ if (merged[key] && !/^https?:\/\//.test(merged[key])) throw Error("Use an http or https link for " + key.replace("_", " ") + ".");445+ }446+ return merged;447+ }448+449+ static relays(text) {450+ const seen = new Set();451+ for (const raw of String(text || "").split(/[\s,]+/)) {452+ const url = tiny.util.relayURL(raw);453+ if (url) seen.add(url);454+ }455+ if (seen.size > 12) throw Error("List at most 12 relays.");456+ return [...seen];457+ }458+459+ async submit(form) {460+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");461+ const value = name => form.elements[name]?.value ?? "";462+ const values = Object.fromEntries(["name", "display_name", "about", "picture", "banner", "website", "nip05", "lud16"].map(key => [key, value(key)]));463+ const content = ProfileForm.content(values, this.getAttribute("existing") || "{}");464+ const relays = ProfileForm.relays(value("relays"));465+ const unsigned = {kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify(content)};466+ const expected = JSON.stringify(unsigned);467+ this.report("Signing…");468+ const event = await window.nostr.signEvent(JSON.parse(expected));469+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});470+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");471+ const pubkey = this.getAttribute("pubkey");472+ if (pubkey && event.pubkey !== pubkey) throw Error("The signer holds a different key than the one signed in.");473+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});474+ const result = await response.json().catch(() => ({}));475+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the profile.");476+ this.forget(event.pubkey);477+ const lines = ["Published here."];478+ if (relays.length) {479+ if (!window.NostrSigner?.SimplePool) throw Error("Published here; the signer bundle is still loading, so other relays were skipped.");480+ const pool = new window.NostrSigner.SimplePool();481+ const outcomes = await Promise.allSettled(pool.publish(relays, event).map(p => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(Error("timed out")), 8000))])));482+ outcomes.forEach((outcome, index) => { lines.push(relays[index] + ": " + (outcome.status === "fulfilled" ? "ok" : "failed, " + (outcome.reason?.message || outcome.reason || "no answer"))); });483+ try { pool.close(relays); } catch {}484+ }485+ this.report(lines.join("\n"));486+ }487+488+ // forget drops the cached name so nostr-name shows the new one on the489+ // next page.490+ forget(pubkey) {491+ try {492+ const key = window.tinyNames?.cacheKey || "tiny:names:v1";493+ const cache = JSON.parse(localStorage.getItem(key) || "{}") || {};494+ delete cache[pubkey];495+ localStorage.setItem(key, JSON.stringify(cache));496+ } catch {}497+ }498+ }499+429500 // AgentGrant signs a kind 30392 agent grant: the agent's key, name, scope430501 // and expiry, signed by the owner or a moderator. The agent key is made431502 // here and its nsec shown once, or pasted as a public key. The secret never
@@ -1576,6 +1647,7 @@
15761647 customElements.define("publish-list", PublishList);15771648 customElements.define("nostr-compose", NostrCompose);15781649 customElements.define("agent-grant", AgentGrant);1650+ customElements.define("profile-form", ProfileForm);15791651 customElements.define("nostr-react", NostrReact);15801652 customElements.define("approval-item", ApprovalItem);15811653 customElements.define("wiki-compose", WikiCompose);
@@ -1613,7 +1685,7 @@
16131685 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;16141686 return root ? url.pathname.slice(root.length) || "/" : url.pathname;16151687 };1616- 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 || "");1688+ 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 || "");16171689 let navigationSerial = 0, activeAbort;16181690 const streams = new Set();16191691 const closeStreams = () => {
@@ -1628,7 +1700,7 @@
16281700 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));16291701 window.addEventListener("pagehide", closeStreams);16301702 const repairComponents = () => {1631- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => {1703+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => {16321704 if (node.form?.isConnected) return;16331705 node.form = null;16341706 node.output = null;
M internal/webui/page.html +2 -1
@@ -56,6 +56,7 @@
5656 {{else if eq .Tab "wikipage"}}{{template "wikipage" .}}5757 {{else if eq .Tab "file"}}{{template "file" .}}5858 {{else if eq .Tab "approvals"}}{{template "approvals" .}}59+{{else if eq .Tab "profile"}}{{template "profile" .}}5960 {{else if eq .Tab "rooms"}}{{template "rooms" .}}6061 {{else if eq .Tab "room"}}{{template "room" .}}6162 {{else if eq .Tab "thread"}}{{template "thread" .}}
@@ -113,7 +114,7 @@
113114 {{end}}{{end}}114115 115116 {{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>116-<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>117+<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>117118 <section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}}118119 119120 {{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>
A internal/webui/profile_test.go +85 -0
@@ -0,0 +1,85 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+type profileBackend struct {16+ fakeBackend17+ params json.RawMessage18+}19+20+func (b *profileBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {21+ if method != "browseprofile" {22+ return nil, nil23+ }24+ if actor == "" {25+ return nil, errors.New("auth-required: sign in")26+ }27+ b.params = params[0]28+ 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"}}, nil29+}30+31+func TestProfilePagePrefillsTheFormAndOffersItFromHome(t *testing.T) {32+ backend := &profileBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}33+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})34+ if err != nil {35+ t.Fatal(err)36+ }37+ recorder := httptest.NewRecorder()38+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/profile", nil))39+ body := recorder.Body.String()40+ for _, marker := range []string{41+ `<profile-form pubkey="` + backend.policy.Owner + `" existing="{`,42+ `&#34;name&#34;:&#34;dami&#34;`,43+ `<input name="name" maxlength="64" value="dami"`,44+ `<textarea name="about" maxlength="2000">keeps &lt;cats&gt;</textarea>`,45+ `<input name="lud16" value="dami@wallet.example"`,46+ "wss://relay.one\nwss://relay.two\n</textarea>",47+ `<button>Sign and publish</button>`,48+ "Current profile published <time",49+ } {50+ if !strings.Contains(body, marker) {51+ t.Errorf("profile page missing %q", marker)52+ }53+ }54+ if strings.Contains(body, "class=") {55+ t.Fatal("profile page carries a class attribute")56+ }57+ if !strings.Contains(string(backend.params), `"pubkey":"`+backend.policy.Owner+`"`) {58+ t.Fatalf("profile query params = %s", backend.params)59+ }60+ recorder = httptest.NewRecorder()61+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))62+ home := recorder.Body.String()63+ if !strings.Contains(home, `<li><a href="/profile">profile</a></li>`) || strings.Contains(home, `<a href="/manage/people">manage</a>`) {64+ t.Fatal("home panel does not offer the profile action, or still offers manage")65+ }66+}67+68+func TestProfilePageAsksGuestsToSignIn(t *testing.T) {69+ backend := &profileBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}70+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})71+ if err != nil {72+ t.Fatal(err)73+ }74+ recorder := httptest.NewRecorder()75+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/profile", nil))76+ body := recorder.Body.String()77+ if recorder.Code != http.StatusOK || !strings.Contains(body, "Sign in to edit your profile.") || strings.Contains(body, "<profile-form") {78+ t.Fatalf("guest profile page: %d %s", recorder.Code, body)79+ }80+ recorder = httptest.NewRecorder()81+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))82+ if home := recorder.Body.String(); !strings.Contains(home, `<li><a href="/signin">sign in</a></li>`) || strings.Contains(home, `href="/profile"`) {83+ t.Fatal("guest home panel should offer sign in, not the profile")84+ }85+}
M internal/webui/style.css +4 -4
@@ -146,11 +146,11 @@ agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit}
146146 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}147147 #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}148148 [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)}149-agent-grant{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}150-agent-grant label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}151-agent-grant label input,agent-grant label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}149+agent-grant,profile-form{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}150+agent-grant label,profile-form label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}151+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}152152 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}153-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)}153+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)}154154 #panel rpc-form{border:0;padding:4px 0}#panel rpc-form output:empty{display:none}155155 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)}156156
M internal/webui/webmcp.go +1 -1
M internal/webui/webmcp.js +2 -0
M internal/webui/webui.go +17 -5
@@ -164,7 +164,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
164164 http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)165165 return166166 }167- 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 != "") {167+ 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 != "") {168168 a.browse(writer, request)169169 return170170 }
@@ -530,6 +530,8 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
530530 method = "browsefile"531531 case "/approvals":532532 method = "browseapprovals"533+ case "/profile":534+ method = "browseprofile"533535 case "/wiki":534536 method = "browsewiki"535537 case "/manage/health":
@@ -592,6 +594,8 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
592594 if query["hash"] == "" {593595 query["hash"] = request.URL.Query().Get("sha")594596 }597+ case "browseprofile":598+ query = map[string]any{"pubkey": actor}595599 case "browseapprovals":596600 state := request.URL.Query().Get("state")597601 if state == "" {
@@ -659,7 +663,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
659663 }660664 661665 func browseTitle(path string, query url.Values, result any, slug string) string {662- label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki", "/rooms": "Rooms"}[path]666+ label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/profile": "Profile", "/wiki": "Wiki", "/rooms": "Rooms"}[path]663667 if strings.HasPrefix(path, "/wiki/") {664668 label = plainString(valueMap(result)["title"])665669 if label == "" {
@@ -744,6 +748,8 @@ func browseTab(path string) string {
744748 return "file"745749 case "/approvals":746750 return "approvals"751+ case "/profile":752+ return "profile"747753 default:748754 return "health"749755 }
@@ -787,10 +793,14 @@ func (a *App) render(writer http.ResponseWriter, request *http.Request, data Pag
787793 }788794 data.Path = strings.TrimPrefix(request.URL.Path, data.Base)789795 a.privatePageData(&data, request, actor)790- if !data.Private && data.Actor == "" && railKind(data.Tab) == "manage" {791- // Management pages are for signed-in people only. Guests see the792- // sign-in page at the same address and come back after signing in.796+ if !data.Private && data.Actor == "" && (railKind(data.Tab) == "manage" || data.Tab == "profile") {797+ // Management pages and the profile editor are for signed-in people798+ // only. Guests see the sign-in page at the same address and come back799+ // after signing in.793800 data.Tab, data.Notice = "signin", "Sign in to manage this relay."801+ if data.Path == "/profile" {802+ data.Notice = "Sign in to edit your profile."803+ }794804 data.Event, data.Feed, data.Error = nil, nil, ""795805 }796806 writer.Header().Set("Cache-Control", "private, no-store")
@@ -1235,6 +1245,8 @@ func tabForPath(path string) string {
12351245 return "file"12361246 case "approvals":12371247 return "approvals"1248+ case "profile":1249+ return "profile"12381250 case "signin", "sites":12391251 return path12401252 case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":
M package.json +2 -1
A scripts/profile-form.test.mjs +75 -0
@@ -0,0 +1,75 @@
1+// The profile form merges the fields it shows over the profile the relay2+// holds, signs a kind 0, publishes it here and then to the listed relays.3+import test from "node:test";4+import assert from "node:assert/strict";5+import fs from "node:fs";6+import vm from "node:vm";7+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";8+9+const source = fs.readFileSync("internal/webui/components.js", "utf8");10+const formSource = source.slice(source.indexOf(" // ProfileForm publishes"), source.indexOf(" // AgentGrant signs"));11+const plain = value => JSON.parse(JSON.stringify(value));12+13+function setup({attributes = {}, result, pool, noSigner = false, otherKey = false} = {}) {14+ const secret = generateSecretKey(), pubkey = getPublicKey(secret);15+ const sent = [], published = [], reports = [];16+ class FormElement {17+ getAttribute(name) { return attributes[name] ?? (name === "pubkey" ? (otherKey ? "f".repeat(64) : pubkey) : null); }18+ report(text, error) { reports.push({text, error: Boolean(error)}); }19+ }20+ const store = {};21+ const localStorage = {getItem: key => store[key] ?? null, setItem: (key, value) => { store[key] = value; }};22+ const window = {23+ nostr: noSigner ? undefined : {signEvent: async event => finalizeEvent(structuredClone(event), secret)},24+ 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() {} }},25+ tinyNames: {cacheKey: "tiny:names:v1"}26+ };27+ const tiny = {28+ util: {relayURL: value => (/^wss?:\/\/[a-z0-9.-]+(?::\d+)?\/?$/i.test(String(value).trim()) ? String(value).trim().replace(/\/$/, "") : null)},29+ signedFetch: async (path, method, body) => { sent.push({path, method, event: JSON.parse(body)}); return Response.json(result ?? {accepted: true}); }30+ };31+ const Constructor = vm.runInNewContext(`${formSource}\nProfileForm`, {FormElement, window, tiny, localStorage, JSON, Date, Error, Promise, setTimeout, Object, Array, String});32+ const form = values => ({elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}]))});33+ return {Constructor, component: new Constructor(), form, sent, published, reports, pubkey, store};34+}35+36+test("content merges shown fields over the held profile and validates links", () => {37+ const {Constructor} = setup();38+ 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}}));39+ assert.deepEqual(plain(merged), {name: "dami", about: "hi", picture: "https://x/p.png", website: "https://kept.example", custom: {deep: true}});40+ assert.deepEqual(plain(Constructor.content({name: "x"}, "not json")), {name: "x"});41+ assert.throws(() => Constructor.content({picture: "javascript:alert(1)"}, "{}"), /http or https/);42+});43+44+test("relays parse from any separator, dedupe and cap at twelve", () => {45+ const {Constructor} = setup();46+ assert.deepEqual(plain(Constructor.relays("wss://a.example/\nwss://b.example, wss://a.example ftp://no.example")), ["wss://a.example", "wss://b.example"]);47+ assert.throws(() => Constructor.relays(Array.from({length: 13}, (_, i) => "wss://r" + i + ".example").join(" ")), /at most 12/);48+});49+50+test("submit publishes here first, then to the listed relays, and forgets the cached name", async () => {51+ const s = setup({pool: {"wss://b.example": "fail"}});52+ s.store["tiny:names:v1"] = JSON.stringify({[s.pubkey]: {event: null, at: 1}, other: {at: 2}});53+ await s.component.submit(s.form({name: "dami", about: "", relays: "wss://a.example\nwss://b.example"}));54+ assert.equal(s.sent.length, 1);55+ assert.equal(s.sent[0].path, "/events");56+ const event = s.sent[0].event;57+ assert.equal(event.kind, 0);58+ assert.deepEqual(plain(JSON.parse(event.content)), {name: "dami"});59+ assert.equal(verifyEvent(event), true);60+ assert.deepEqual(plain(s.published[0].relays), ["wss://a.example", "wss://b.example"]);61+ assert.equal(s.published[0].event.id, event.id);62+ const last = s.reports.at(-1).text.split("\n");63+ assert.deepEqual(last, ["Published here.", "wss://a.example: ok", "wss://b.example: failed, closed"]);64+ assert.deepEqual(Object.keys(JSON.parse(s.store["tiny:names:v1"])), ["other"]);65+});66+67+test("submit refuses without a signer, with another key, or when the relay rejects", async () => {68+ await assert.rejects(setup({noSigner: true}).component.submit(setup().form({name: "x"})), /Connect a signer/);69+ const other = setup({otherKey: true});70+ await assert.rejects(other.component.submit(other.form({name: "x"})), /different key/);71+ assert.equal(other.sent.length, 0);72+ const refused = setup({result: {accepted: false, message: "blocked: no"}});73+ await assert.rejects(refused.component.submit(refused.form({name: "x"})), /blocked: no/);74+ assert.equal(refused.published.length, 0);75+});
M scripts/webmcp.test.mjs +14 -0