tinyrelaycommit

Commit bce2c385a544

browse files at this commit | history

1diff --git a/docs/fixi.md b/docs/fixi.md
2index f2de929..7c3c95e 100644
3--- a/docs/fixi.md
4+++ b/docs/fixi.md
5@@ -8,7 +8,7 @@ The vendored file's SHA-256 is:
6 b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855
7 ```
8
9-`/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Connect page uses Fixi's `fx-action` fragment request for a read-only connection preview; `/connect/fragment` renders server-escaped table HTML, while `/connect.json` remains a JSON API. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them.
10+`/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Sync page uses Fixi's `fx-action` request for the job status stream, which renders server-escaped table HTML; `/connect.json` remains a JSON API for the configured connections. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them.
11
12 The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.
13
14diff --git a/internal/webui/components.js b/internal/webui/components.js
15index 1eb60aa..3e91365 100644
16--- a/internal/webui/components.js
17+++ b/internal/webui/components.js
18@@ -5,6 +5,9 @@
19 // <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params]>
20 // One signed NIP-86 management call. Children become the form body; the
21 // element appends an <output> status line and a <json-view> result.
22+// <connect-list>
23+// The home page cards: lists, reorders, retargets and removes configured
24+// connections and adds from the catalog, saving with setconnections.
25 // <signed-form action="…" method="PUT|POST|GET" [download]>
26 // A NIP-98 signed upload of one file, or a signed download by name.
27 // <publish-list kind="…" tag="…" [scheme="ws"]>
28@@ -84,6 +87,24 @@
29 }]
30 };
31
32+ // rpc makes one signed management call: the body is serialised once,
33+ // signed as NIP-98, and sent once. Returns the result or throws the error.
34+ const rpc = async (method, params = [], action) => {
35+ const body = JSON.stringify({method, params});
36+ const endpoint = tiny.localPath(action || "/manage/rpc");
37+ const url = new URL(endpoint, location.href).href;
38+ const response = await fetch(endpoint, {
39+ method: "POST",
40+ headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))},
41+ body
42+ });
43+ const text = await response.text();
44+ let payload;
45+ try { payload = JSON.parse(text); } catch { payload = undefined; }
46+ if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")");
47+ return payload && "result" in payload ? payload.result : payload ?? text;
48+ };
49+
50 class RpcForm extends FormElement {
51 params(form) {
52 const compose = composers[this.getAttribute("compose")];
53@@ -105,20 +126,8 @@
54 const terms = this.getAttribute("terms");
55 if (terms) params = [params[0], await tiny.sha256hex(new TextEncoder().encode(terms))];
56 }
57- const body = JSON.stringify({method, params});
58 this.report("Signing…");
59- const endpoint = tiny.localPath(this.getAttribute("action")||"/manage/rpc");
60- const url = new URL(endpoint, location.href).href;
61- const response = await fetch(endpoint, {
62- method: "POST",
63- headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))},
64- body
65- });
66- const text = await response.text();
67- let payload;
68- try { payload = JSON.parse(text); } catch { payload = undefined; }
69- if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")");
70- const result = payload && "result" in payload ? payload.result : payload ?? text;
71+ const result = await rpc(method, params, this.getAttribute("action"));
72 this.report("Done.");
73 this.show(result);
74 }
75@@ -390,6 +399,130 @@
76 }
77 }
78
79+ // ConnectList edits the cards on the home page: the configured connections
80+ // in order, each with its audience, plus add from the catalog. Every change
81+ // saves the whole list with one signed call.
82+ class ConnectList extends HTMLElement {
83+ connectedCallback() {
84+ if (this.bound) return;
85+ this.bound = true;
86+ this.body = this.querySelector("tbody");
87+ this.select = this.querySelector("select[name=template]");
88+ this.output = this.querySelector("output");
89+ this.rows = [];
90+ this.catalog = [];
91+ this.addEventListener("click", event => {
92+ const button = event.target.closest("button[name]");
93+ if (!button) return;
94+ button.disabled = true;
95+ this.change(button.name, button.closest("tr")).catch(err => this.say("error: " + err.message, true)).finally(() => { button.disabled = false; });
96+ });
97+ this.addEventListener("change", event => {
98+ const select = event.target.closest("select[name=visibility]");
99+ if (select) this.change("visibility", select.closest("tr"), select.value).catch(err => this.say("error: " + err.message, true));
100+ });
101+ this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true)));
102+ }
103+
104+ // whenSigned waits briefly for a signer, since a remote one resumes async.
105+ whenSigned(fn, tries = 20) {
106+ if (window.nostr?.signEvent) return fn();
107+ if (tries <= 0) { this.say("connect a signer, then reload this page"); return; }
108+ setTimeout(() => this.whenSigned(fn, tries - 1), 500);
109+ }
110+
111+ say(text, error) {
112+ this.output.textContent = text;
113+ if (error) this.output.dataset.error = "";
114+ else delete this.output.dataset.error;
115+ }
116+
117+ async load() {
118+ this.say("loading…");
119+ [this.rows, this.catalog] = await Promise.all([rpc("listconnections"), rpc("listconnectiontemplates")]);
120+ this.render();
121+ this.say("");
122+ }
123+
124+ entry(name) { return this.catalog.find(c => c.name === name) || {name, title: name, visibility: "public", available: true}; }
125+
126+ render() {
127+ const audiences = ["public", "members", "owner"];
128+ const rows = this.rows.map((row, index) => {
129+ const name = row.template || row.name;
130+ const c = this.entry(name);
131+ const tr = el("tr");
132+ tr.dataset.index = index;
133+ tr.dataset.template = name;
134+ const app = el("td");
135+ if (c.app) app.append(el("b", c.app), " ");
136+ app.append(c.about || "");
137+ if (c.available === false) app.append(" ", el("small", "(feature off, hidden)"));
138+ const audience = el("select");
139+ audience.name = "visibility";
140+ const current = row.visibility === "auth" || row.visibility === "member" ? "members" : row.visibility || c.visibility;
141+ for (const value of audiences.includes(current) ? audiences : [current, ...audiences]) {
142+ const option = el("option", value);
143+ option.value = value;
144+ option.selected = value === current;
145+ audience.append(option);
146+ }
147+ const actions = el("td");
148+ for (const [label, action] of [["up", "up"], ["down", "down"], ["remove", "remove"]]) {
149+ const button = el("button", label);
150+ button.type = "button";
151+ button.name = action;
152+ if ((action === "up" && index === 0) || (action === "down" && index === this.rows.length - 1)) button.disabled = true;
153+ actions.append(button, " ");
154+ }
155+ const cell = el("td");
156+ cell.append(audience);
157+ tr.append(el("td", c.title || name), app, cell, actions);
158+ return tr;
159+ });
160+ if (!rows.length) {
161+ const empty = el("tr");
162+ const cell = el("td", "No cards. Add one from the catalog below.");
163+ cell.colSpan = 4;
164+ empty.append(cell);
165+ rows.push(empty);
166+ }
167+ this.body.replaceChildren(...rows);
168+ const used = new Set(this.rows.map(row => row.template || row.name));
169+ const options = [el("option", "choose one")];
170+ options[0].value = "";
171+ for (const c of this.catalog) {
172+ if (used.has(c.name)) continue;
173+ const option = el("option", c.title + (c.app ? " (" + c.app + ")" : "") + (c.available === false ? ", feature off" : ""));
174+ option.value = c.name;
175+ options.push(option);
176+ }
177+ this.select.replaceChildren(...options);
178+ }
179+
180+ async change(action, row, value) {
181+ const index = Number(row?.dataset.index);
182+ const next = this.rows.map(entry => ({...entry}));
183+ switch (action) {
184+ case "remove": next.splice(index, 1); break;
185+ case "up": if (index > 0) [next[index - 1], next[index]] = [next[index], next[index - 1]]; break;
186+ case "down": if (index < next.length - 1) [next[index + 1], next[index]] = [next[index], next[index + 1]]; break;
187+ case "visibility": next[index].visibility = value; break;
188+ case "add": {
189+ const name = this.select.value;
190+ if (!name) { this.say("choose a card to add"); return; }
191+ next.push({template: name, visibility: this.entry(name).visibility || "public"});
192+ break;
193+ }
194+ default: return;
195+ }
196+ this.say("saving…");
197+ this.rows = await rpc("setconnections", [next]);
198+ this.render();
199+ this.say("saved");
200+ }
201+ }
202+
203 // ConnectCard copies a command or address, filling {input:name} from the
204 // card's inputs, so a clone command carries the repository name typed.
205 class ConnectCard extends HTMLElement {
206@@ -413,6 +546,7 @@
207
208 customElements.define("rpc-form", RpcForm);
209 customElements.define("connect-card", ConnectCard);
210+ customElements.define("connect-list", ConnectList);
211 customElements.define("relay-lists", RelayLists);
212 customElements.define("signed-form", SignedForm);
213 customElements.define("publish-list", PublishList);
214diff --git a/internal/webui/manage.html b/internal/webui/manage.html
215index c481c02..b3a04e4 100644
216--- a/internal/webui/manage.html
217+++ b/internal/webui/manage.html
218@@ -101,11 +101,15 @@
219 <h1>Connect</h1>
220 {{if .Private}}<p>This private relay requires current membership. Connect a signer to sign in.</p>
221 {{end}}<h3>Connections</h3>
222-<p><button fx-action="/connect/fragment" fx-method="get" fx-trigger="click" fx-target="#connections-preview" fx-swap="morph">Preview connections</button></p>
223-<scroll-box><table id="connections-preview" aria-live="polite"><tbody><tr><td>No preview loaded.</td></tr></tbody></table></scroll-box>
224-<rpc-form method="listconnections"><button>List connections</button></rpc-form>
225-<rpc-form method="setconnections"><label>JSON connections <textarea name="param">[]</textarea></label><button>Save connections</button></rpc-form>
226-<rpc-form method="listconnectiontemplates"><button>List templates</button></rpc-form>
227+<p>The cards on the home page, in order. Remove what you will not use; add more from the catalog. Cards whose feature is off stay hidden.</p>
228+<connect-list>
229+<table id="rows"><thead><tr><th>card</th><th>app</th><th>audience</th><th></th></tr></thead><tbody><tr><td colspan="4">Loading…</td></tr></tbody></table>
230+<p><label>Add a card <select name="template"><option value="">choose one</option></select></label> <button type="button" name="add">add</button></p>
231+<output role="status"></output>
232+</connect-list>
233+<details><summary>Edit as JSON</summary>
234+<rpc-form method="setconnections"><label>Connections <textarea name="param">[]</textarea></label><button>Save connections</button></rpc-form>
235+</details>
236 <h3>Publish signed relay records</h3>
237 <p>These actions open the configured signer and publish owner-authored Nostr records.</p>
238 <publish-list kind="10317" tag="g" scheme="ws">
239diff --git a/internal/webui/style.css b/internal/webui/style.css
240index 5b67461..d1485a4 100644
241--- a/internal/webui/style.css
242+++ b/internal/webui/style.css
243@@ -111,6 +111,7 @@ rpc-form output,signed-form output,publish-list output{display:block;font:12px v
244 rpc-form output[data-error]{color:var(--alert)}
245 #content>section>h3{border-bottom:0;margin-top:22px;font-size:15px}
246
247+connect-list{display:block}connect-list #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}connect-list #rows td:first-child{width:150px}connect-list #rows td:nth-child(2){color:var(--muted)}connect-list #rows td:nth-child(2) b{color:var(--ink);font-weight:normal}connect-list #rows td:nth-child(3){width:110px}connect-list #rows td:last-child{width:190px;white-space:nowrap}connect-list button,connect-list select{font-size:12px}connect-list output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px}connect-list output[data-error]{color:var(--alert)}
248 relay-lists{display:block}relay-lists #rows td{height:auto;padding:8px 10px;white-space:normal;vertical-align:middle}relay-lists #rows td:last-child{width:230px;text-align:left;color:var(--ink);white-space:nowrap}relay-lists #rows td:first-child{width:170px;color:var(--ink);font-family:var(--sans)}relay-lists output{display:block;font:12px var(--mono);color:var(--muted);white-space:normal}relay-lists button{font-size:12px}
249
250 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)}
251@@ -194,6 +195,6 @@ html,body{overflow:auto}
252 #navigation-status:not(:empty),#session-status:not(:empty){position:absolute;bottom:100%;left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal}
253 #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto}
254 #tree{border-right:0;border-bottom:1px solid var(--line)}
255-#rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px}
256+#rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px}connect-list #rows td:nth-child(2),connect-list #rows th:nth-child(2){display:none}connect-list #rows td:nth-child(3),connect-list #rows td:last-child{width:auto}connect-list #rows td:last-child{white-space:normal}connect-list #rows td:last-child button{margin:2px 0}
257 #grid,#gridhead,h2:has(+#gridhead){display:none}
258 }
259diff --git a/internal/webui/webui.go b/internal/webui/webui.go
260index dfbf966..764afeb 100644
261--- a/internal/webui/webui.go
262+++ b/internal/webui/webui.go
263@@ -167,10 +167,6 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
264 a.connectJSON(writer, request)
265 return
266 }
267- if request.URL.Path == "/connect/fragment" {
268- a.connectFragment(writer, request)
269- return
270- }
271 if strings.HasPrefix(request.URL.Path, "/e/") || strings.HasPrefix(request.URL.Path, "/a/") {
272 if !a.pageFeatureEnabled(request.URL.Path) {
273 http.NotFound(writer, request)
274@@ -404,27 +400,6 @@ func (a *App) connectJSON(writer http.ResponseWriter, request *http.Request) {
275 writeJSON(writer, http.StatusOK, result)
276 }
277
278-func (a *App) connectFragment(writer http.ResponseWriter, request *http.Request) {
279- actor, actorErr := a.resolveActor(request)
280- if actorErr != nil || actor == "" {
281- http.Error(writer, "authentication required", http.StatusUnauthorized)
282- return
283- }
284- result, err := a.backend.Query(request.Context(), "listconnections", nil, actor)
285- if err != nil {
286- http.Error(writer, "connections unavailable", http.StatusNotFound)
287- return
288- }
289- rows := make([][]string, 0)
290- for _, row := range browseRows(result) {
291- values, _ := row.(map[string]any)
292- rows = append(rows, []string{plainString(firstValue(values, "label", "title", "name", "template")), plainString(firstValue(values, "url", "href", "relay")), plainString(firstValue(values, "status", "visibility"))})
293- }
294- fragment := `<table id="connections-preview" aria-live="polite">` + tableRows([]string{"Label", "Relay", "Status"}, rows) + `</table>`
295- writer.Header().Set("content-type", "text/html; charset=utf-8")
296- _, _ = writer.Write([]byte(fragment))
297-}
298-
299 func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {
300 actor, actorErr := a.resolveActor(request)
301 if actorErr != nil {
302diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
303index e58cea7..31c0831 100644
304--- a/internal/webui/webui_test.go
305+++ b/internal/webui/webui_test.go
306@@ -313,16 +313,24 @@ func TestJobStatusRequiresActorAndEmitsHTMLSSE(t *testing.T) {
307 }
308 }
309
310-func TestConnectionFragmentEscapesValues(t *testing.T) {
311+func TestConnectPageEditsConnectionsAsList(t *testing.T) {
312 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
313- app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "owner", nil }})
314+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
315 if err != nil {
316 t.Fatal(err)
317 }
318 recorder := httptest.NewRecorder()
319- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/connect/fragment", nil))
320- if recorder.Code != http.StatusOK || !strings.Contains(recorder.Header().Get("content-type"), "text/html") || !strings.Contains(recorder.Body.String(), "connections-preview") {
321- t.Fatalf("invalid connection fragment: status=%d body=%s", recorder.Code, recorder.Body.String())
322+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))
323+ body := recorder.Body.String()
324+ for _, want := range []string{"<connect-list>", `<select name="template">`, `<button type="button" name="add">`, `method="setconnections"`} {
325+ if !strings.Contains(body, want) {
326+ t.Errorf("connect page missing %q", want)
327+ }
328+ }
329+ for _, gone := range []string{"connect/fragment", "Preview connections", `method="listconnections"`} {
330+ if strings.Contains(body, gone) {
331+ t.Errorf("connect page still carries %q", gone)
332+ }
333 }
334 }
335
336@@ -333,9 +341,9 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
337 t.Fatal(err)
338 }
339 recorder := httptest.NewRecorder()
340- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/connect", nil))
341+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/sync", nil))
342 body := recorder.Body.String()
343- for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/connect/fragment"`, `localPath(this.getAttribute("action")||"/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} {
344+ for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `localPath(action || "/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} {
345 if !strings.Contains(body, want) {
346 t.Errorf("tenant prefix missing %q", want)
347 }
348@@ -348,11 +356,11 @@ func TestTenantPrefixHeaderSurvivesDaemonPathStripping(t *testing.T) {
349 if err != nil {
350 t.Fatal(err)
351 }
352- request := httptest.NewRequest(http.MethodGet, "/manage/connect", nil)
353- request.RequestURI = "/r/alice/manage/connect"
354+ request := httptest.NewRequest(http.MethodGet, "/manage/sync", nil)
355+ request.RequestURI = "/r/alice/manage/sync"
356 recorder := httptest.NewRecorder()
357 app.ServeHTTP(recorder, request)
358- if !strings.Contains(recorder.Body.String(), `fx-action="/r/alice/connect/fragment"`) {
359+ if !strings.Contains(recorder.Body.String(), `fx-action="/r/alice/manage/jobs/status"`) {
360 t.Fatal("daemon prefix header was not applied")
361 }
362 }
363diff --git a/scripts/qa/ux-browser-flow.js b/scripts/qa/ux-browser-flow.js
364index 5d0f116..eb86111 100644
365--- a/scripts/qa/ux-browser-flow.js
366+++ b/scripts/qa/ux-browser-flow.js
367@@ -59,29 +59,24 @@ async page => {
368 check(current.length > 0 && current.every(x => x.form && x.output && !x.nested), 'detached or nested forms: '+JSON.stringify(current));
369 }
370 await page.waitForFunction(() => !!window.nostr?.signEvent);
371- const list = page.locator('rpc-form[method=listconnections]');
372+ await page.waitForFunction(() => document.querySelector('connect-list #rows tbody tr[data-index]') !== null);
373+ check(await page.locator('connect-list select[name=template] option').count() > 1, 'connect list offers no catalog cards');
374+ await clickNav('/people');
375+ const list = page.locator('rpc-form[method=listmembers]');
376 let submissions = 0;
377 const count = request => { if (request.method()==='POST' && request.url().endsWith('/manage/rpc')) submissions++; };
378 page.on('request', count);
379 try {
380 await list.evaluate(node => { node.form.requestSubmit(); node.form.requestSubmit(); });
381- await page.waitForFunction(() => document.querySelector('rpc-form[method=listconnections] output')?.textContent === 'Done.');
382+ await page.waitForFunction(() => document.querySelector('rpc-form[method=listmembers] output')?.textContent === 'Done.');
383 check(submissions===1, 'duplicate form submissions sent '+submissions+' signed requests');
384 } finally { page.off('request',count); }
385- await page.getByRole('button', {name:'Preview connections',exact:true}).click();
386- await page.waitForFunction(() => document.querySelector('#connections-preview thead') !== null);
387+ await clickNav('/connect');
388+ await page.waitForFunction(() => document.querySelector('connect-list #rows tbody tr[data-index]') !== null);
389 const result = await forms();
390 await page.screenshot({path:'output/playwright/ux/connect-desktop.png'});
391 return result;
392 });
393- await run('refused previews show an error without destroying the table', async () => {
394- await page.evaluate(async () => { await fetch(window.tiny.localPath('/session/logout'), {method:'POST'}); });
395- try {
396- await page.getByRole('button', {name:'Preview connections',exact:true}).click();
397- await page.waitForFunction(() => document.querySelector('#navigation-status')?.textContent.includes('Sign in again'));
398- check(await page.locator('#connections-preview').count()===1, 'refused preview removed table');
399- } finally { await page.evaluate(async () => { await window.tiny.signedFetch('/session','POST',''); }); }
400- });
401 await run('job status stream closes when leaving the page', async () => {
402 await clickNav('/sync');
403 await page.locator('#watch-jobs').click();

About

ownerc01503e8035f
visibilitypublic
headbce2c385a544

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs