| 1 | diff --git a/docs/fixi.md b/docs/fixi.md |
| 2 | index 74a4441..5296908 100644 |
| 3 | --- a/docs/fixi.md |
| 4 | +++ b/docs/fixi.md |
| 5 | @@ -12,7 +12,7 @@ b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855 |
| 6 | |
| 7 | The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Encrypted uploads, NIP-34 publishing and NIP-17 file messages sign through the same bridge and never retry a signed request on their own. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones. |
| 8 | |
| 9 | -Signed actions require JavaScript and submit once while their controls are disabled. Connect previews update in place. Job status uses an authenticated stream that sends updates when the results change. |
| 10 | +Signed-in browsers read the connection list and the query bridge with their session, so opening a page never asks the signer; signatures are requested only when publishing or decrypting. Signed actions require JavaScript and submit once while their controls are disabled. Connect previews update in place. Job status uses an authenticated stream that sends updates when the results change. |
| 11 | |
| 12 | On phones, navigation starts closed and opens from the menu control, with or without JavaScript. Opening the menu leaves content in place. Desktop pages retain the sidebar. |
| 13 | |
| 14 | diff --git a/internal/daemon/http.go b/internal/daemon/http.go |
| 15 | index aaf918e..0af5d8b 100644 |
| 16 | --- a/internal/daemon/http.go |
| 17 | +++ b/internal/daemon/http.go |
| 18 | @@ -225,6 +225,26 @@ func (t *Tenant) session(r *http.Request, body []byte) (relay.Session, error) { |
| 19 | return s, nil |
| 20 | } |
| 21 | |
| 22 | +// cookieReadSession lets a signed-in browser read through the query bridge |
| 23 | +// without a signature, so pages do not prompt the signer just to show data. |
| 24 | +// The Origin check keeps another site from reading with a visitor's cookie; |
| 25 | +// publishing still needs a signed event. |
| 26 | +func (t *Tenant) cookieReadSession(r *http.Request, s relay.Session) (relay.Session, error) { |
| 27 | + base, err := url.Parse(t.requestURL(r)) |
| 28 | + if err != nil || r.Header.Get("Origin") != base.Scheme+"://"+base.Host { |
| 29 | + return s, errors.New("auth-required: sign this request") |
| 30 | + } |
| 31 | + actor, err := t.cookieActor(r) |
| 32 | + if err != nil { |
| 33 | + return s, err |
| 34 | + } |
| 35 | + if actor == "" { |
| 36 | + return s, errors.New("auth-required: sign this request") |
| 37 | + } |
| 38 | + s.PubKeys = []string{actor} |
| 39 | + return s, nil |
| 40 | +} |
| 41 | + |
| 42 | func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) { |
| 43 | body, err := t.requestBody(w, r) |
| 44 | if err != nil { |
| 45 | @@ -232,6 +252,9 @@ func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) { |
| 46 | return |
| 47 | } |
| 48 | s, err := t.session(r, body) |
| 49 | + if err != nil && r.URL.Path != "/events" && r.Header.Get("Authorization") == "" { |
| 50 | + s, err = t.cookieReadSession(r, s) |
| 51 | + } |
| 52 | if err != nil { |
| 53 | writeJSON(w, 401, map[string]string{"error": err.Error()}) |
| 54 | return |
| 55 | diff --git a/internal/daemon/session_test.go b/internal/daemon/session_test.go |
| 56 | index b0b9e53..0b1d9d7 100644 |
| 57 | --- a/internal/daemon/session_test.go |
| 58 | +++ b/internal/daemon/session_test.go |
| 59 | @@ -58,6 +58,31 @@ func TestSignedSessionSurvivesNavigationAndRevokes(t *testing.T) { |
| 60 | if err != nil || actor != tenant.Policy().Owner { |
| 61 | t.Fatalf("session actor %s %v", actor, err) |
| 62 | } |
| 63 | + // Reads through the query bridge use the session when the request comes |
| 64 | + // from this origin; foreign origins and publishing still need a signature. |
| 65 | + query := func(path, origin string) int { |
| 66 | + req := httptest.NewRequest("POST", "http://relay.test"+path, strings.NewReader(`[{"kinds":[10318],"limit":1}]`)) |
| 67 | + req.Header.Set("Content-Type", "application/json") |
| 68 | + if origin != "" { |
| 69 | + req.Header.Set("Origin", origin) |
| 70 | + } |
| 71 | + req.AddCookie(cookies[0]) |
| 72 | + res := httptest.NewRecorder() |
| 73 | + a.ServeHTTP(res, req) |
| 74 | + return res.Code |
| 75 | + } |
| 76 | + if code := query("/query", "http://relay.test"); code != 200 { |
| 77 | + t.Fatalf("session query %d", code) |
| 78 | + } |
| 79 | + if code := query("/query", "https://foreign.test"); code != 401 { |
| 80 | + t.Fatalf("foreign origin query %d", code) |
| 81 | + } |
| 82 | + if code := query("/query", ""); code != 401 { |
| 83 | + t.Fatalf("query without origin %d", code) |
| 84 | + } |
| 85 | + if code := query("/events", "http://relay.test"); code != 401 { |
| 86 | + t.Fatalf("cookie publish %d", code) |
| 87 | + } |
| 88 | logout := httptest.NewRequest("POST", "http://relay.test/session/logout", nil) |
| 89 | logout.AddCookie(cookies[0]) |
| 90 | logout.Header.Set("Origin", "https://foreign.test") |
| 91 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 92 | index a951050..d0cfb0b 100644 |
| 93 | --- a/internal/webui/components.js |
| 94 | +++ b/internal/webui/components.js |
| 95 | @@ -607,7 +607,7 @@ |
| 96 | const select = event.target.closest("select[name=visibility]"); |
| 97 | if (select) this.change("visibility", select.closest("tr"), select.value).catch(err => this.say("error: " + err.message, true)); |
| 98 | }); |
| 99 | - this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true))); |
| 100 | + this.load().catch(err => this.say("error: " + err.message, true)); |
| 101 | } |
| 102 | |
| 103 | // whenSigned runs once a signer is available; the bridge announces a |
| 104 | @@ -624,8 +624,23 @@ |
| 105 | else delete this.output.dataset.error; |
| 106 | } |
| 107 | |
| 108 | + // load reads with the browser session first so showing the page never |
| 109 | + // asks the signer; a signed call is the fallback without a session. |
| 110 | async load() { |
| 111 | this.say("loading…"); |
| 112 | + const response = await fetch(tiny.localPath("/connect.json?catalog=1"), {credentials: "same-origin", headers: {Accept: "application/json"}}); |
| 113 | + if (response.status === 401) { |
| 114 | + this.whenSigned(() => this.loadSigned().catch(err => this.say("error: " + err.message, true))); |
| 115 | + return; |
| 116 | + } |
| 117 | + if (!response.ok) throw Error("Request failed (" + response.status + ")"); |
| 118 | + const data = await response.json(); |
| 119 | + this.rows = data.connections; |
| 120 | + this.catalog = data.catalog; |
| 121 | + this.render(); |
| 122 | + this.say(""); |
| 123 | + } |
| 124 | + async loadSigned() { |
| 125 | [this.rows, this.catalog] = await Promise.all([rpc("listconnections"), rpc("listconnectiontemplates")]); |
| 126 | this.render(); |
| 127 | this.say(""); |
| 128 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 129 | index 61e2ce7..d9ddef3 100644 |
| 130 | --- a/internal/webui/manage.html |
| 131 | +++ b/internal/webui/manage.html |
| 132 | @@ -112,10 +112,10 @@ |
| 133 | </details> |
| 134 | <h3>Publish signed relay records</h3> |
| 135 | <p>These actions open the configured signer and publish owner-authored Nostr records.</p> |
| 136 | -<private-services> |
| 137 | +<private-services pubkey="{{.Actor}}"> |
| 138 | <h3>Private Git relay list</h3> |
| 139 | <p>GRASP-08 stores private relay URLs in your encrypted kind 10318 list. The relay URLs and any list entries are encrypted to your signer with NIP-44.</p> |
| 140 | -<form><label>Private relay URLs, one per line <textarea name="lines" placeholder="wss://private-relay.example"></textarea></label><button disabled>Publish encrypted list</button></form> |
| 141 | +<form><label>Private relay URLs, one per line <textarea name="lines" placeholder="wss://private-relay.example"></textarea></label><button disabled>Publish encrypted list</button> <button type="button" data-decrypt hidden>Decrypt to edit</button></form> |
| 142 | <noscript><p>Connect a signer and enable JavaScript to encrypt your private service list.</p></noscript> |
| 143 | <output role="status"></output> |
| 144 | </private-services> |
| 145 | diff --git a/internal/webui/private-services.js b/internal/webui/private-services.js |
| 146 | index 1435b7b..ef3b280 100644 |
| 147 | --- a/internal/webui/private-services.js |
| 148 | +++ b/internal/webui/private-services.js |
| 149 | @@ -9,17 +9,33 @@ |
| 150 | const relay = value => relayURL(value, {strict: true}); |
| 151 | const mergeRows = (rows, urls) => |
| 152 | rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value])); |
| 153 | - const publicKey = async () => { |
| 154 | + const publicKey = async element => { |
| 155 | + const known = element?.getAttribute("pubkey"); |
| 156 | + if (/^[0-9a-f]{64}$/.test(known || "")) return known; |
| 157 | const active = signer(); |
| 158 | if (active?.getPublicKey) return active.getPublicKey(); |
| 159 | if (window.tiny?.getPublicKey) return window.tiny.getPublicKey(); |
| 160 | throw Error("Connect a signer first."); |
| 161 | }; |
| 162 | + // readList uses the browser session when the page has one, so showing |
| 163 | + // the list never asks the signer; a signed query is the fallback. |
| 164 | const readList = async pubkey => { |
| 165 | const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]); |
| 166 | - const response = await window.tiny.signedFetch("/query", "POST", body, { |
| 167 | - contentType: "application/json" |
| 168 | - }); |
| 169 | + let response = null; |
| 170 | + if (typeof fetch === "function" && window.tiny?.localPath) { |
| 171 | + response = await fetch(window.tiny.localPath("/query"), { |
| 172 | + method: "POST", |
| 173 | + credentials: "same-origin", |
| 174 | + headers: {"content-type": "application/json"}, |
| 175 | + body |
| 176 | + }); |
| 177 | + if (response.status === 401) response = null; |
| 178 | + } |
| 179 | + if (!response) { |
| 180 | + response = await window.tiny.signedFetch("/query", "POST", body, { |
| 181 | + contentType: "application/json" |
| 182 | + }); |
| 183 | + } |
| 184 | if (!response || response.ok === false) throw Error("Could not read the private relay list."); |
| 185 | const rows = await response.json(); |
| 186 | if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed."); |
| 187 | @@ -83,13 +99,14 @@ |
| 188 | this.form.querySelector("button").disabled = false; |
| 189 | }); |
| 190 | }); |
| 191 | - this.whenSigned(() => |
| 192 | - this.load() |
| 193 | - .then(() => { |
| 194 | - this.form.querySelector("button").disabled = false; |
| 195 | - }) |
| 196 | - .catch(error => this.say("Error: " + error.message, true)) |
| 197 | - ); |
| 198 | + this.querySelector("[data-decrypt]")?.addEventListener("click", () => { |
| 199 | + this.whenSigned(() => this.reveal().catch(error => this.say("Error: " + error.message, true))); |
| 200 | + }); |
| 201 | + this.load() |
| 202 | + .then(() => { |
| 203 | + this.form.querySelector("button").disabled = false; |
| 204 | + }) |
| 205 | + .catch(error => this.say("Error: " + error.message, true)); |
| 206 | } |
| 207 | |
| 208 | // whenSigned runs once a signer is available. The bridge announces a |
| 209 | @@ -106,10 +123,24 @@ |
| 210 | else delete this.output.dataset.error; |
| 211 | } |
| 212 | |
| 213 | + // load reads the published event without decrypting it. Decryption |
| 214 | + // asks the signer, so it waits for the Decrypt to edit control. |
| 215 | async load() { |
| 216 | this.say("Reading encrypted list…"); |
| 217 | - const pubkey = await publicKey(); |
| 218 | - const event = await readList(pubkey); |
| 219 | + const pubkey = await publicKey(this); |
| 220 | + this.event = await readList(pubkey); |
| 221 | + if (!this.event) { |
| 222 | + this.say("No private relay list published yet."); |
| 223 | + return; |
| 224 | + } |
| 225 | + const control = this.querySelector("[data-decrypt]"); |
| 226 | + if (control) control.hidden = false; |
| 227 | + this.say("An encrypted list is published. Decrypt to edit it, or publish new URLs to merge them."); |
| 228 | + } |
| 229 | + |
| 230 | + async reveal() { |
| 231 | + const pubkey = await publicKey(this); |
| 232 | + const event = this.event || (await readList(pubkey)); |
| 233 | if (!event) { |
| 234 | this.say("No private relay list published yet."); |
| 235 | return; |
| 236 | @@ -132,7 +163,7 @@ |
| 237 | throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments."); |
| 238 | const urls = rawURLs.map(relay); |
| 239 | const unique = [...new Set(urls)]; |
| 240 | - const pubkey = await publicKey(); |
| 241 | + const pubkey = await publicKey(this); |
| 242 | const existing = await readList(pubkey); |
| 243 | let rows = []; |
| 244 | if (existing) { |
| 245 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 246 | index 2092ec9..dd502d7 100644 |
| 247 | --- a/internal/webui/webui.go |
| 248 | +++ b/internal/webui/webui.go |
| 249 | @@ -397,7 +397,18 @@ func (a *App) connectJSON(writer http.ResponseWriter, request *http.Request) { |
| 250 | writeJSON(writer, http.StatusNotFound, map[string]string{"error": err.Error()}) |
| 251 | return |
| 252 | } |
| 253 | - writeJSON(writer, http.StatusOK, result) |
| 254 | + if request.URL.Query().Get("catalog") == "" { |
| 255 | + writeJSON(writer, http.StatusOK, result) |
| 256 | + return |
| 257 | + } |
| 258 | + // The Connect page reads the list and the card catalog with its session |
| 259 | + // so showing the page never asks the signer for a signature. |
| 260 | + catalog, err := a.backend.Query(request.Context(), "listconnectiontemplates", nil, actor) |
| 261 | + if err != nil { |
| 262 | + writeJSON(writer, http.StatusNotFound, map[string]string{"error": err.Error()}) |
| 263 | + return |
| 264 | + } |
| 265 | + writeJSON(writer, http.StatusOK, map[string]any{"connections": result, "catalog": catalog}) |
| 266 | } |
| 267 | |
| 268 | func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) { |