- commit
- 0ace0e42789227359ff22b970516432fed6d70c1
- parent
- c656e4458f032c5baeeed9bdd279bd2ee726ce43
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | internal/ | 23 | ++++++++++++ |
| M | internal/ | 25 | +++++++++++++ |
| M | internal/ | 17 | ++++++++- |
| M | internal/ | 4 | +- |
| M | internal/ | 59 | +++++++++++++++++++++++------- |
| M | internal/ | 13 | ++++++- |
@@ -12,7 +12,7 @@ b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855
1212 1313 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.1414 15-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.15+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.1616 1717 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.1818 @@ -225,6 +225,26 @@ func (t *Tenant) session(r *http.Request, body []byte) (relay.Session, error) {
225225 return s, nil226226 }227227 228+// cookieReadSession lets a signed-in browser read through the query bridge229+// without a signature, so pages do not prompt the signer just to show data.230+// The Origin check keeps another site from reading with a visitor's cookie;231+// publishing still needs a signed event.232+func (t *Tenant) cookieReadSession(r *http.Request, s relay.Session) (relay.Session, error) {233+ base, err := url.Parse(t.requestURL(r))234+ if err != nil || r.Header.Get("Origin") != base.Scheme+"://"+base.Host {235+ return s, errors.New("auth-required: sign this request")236+ }237+ actor, err := t.cookieActor(r)238+ if err != nil {239+ return s, err240+ }241+ if actor == "" {242+ return s, errors.New("auth-required: sign this request")243+ }244+ s.PubKeys = []string{actor}245+ return s, nil246+}247+228248 func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) {229249 body, err := t.requestBody(w, r)230250 if err != nil {@@ -232,6 +252,9 @@ func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) {
232252 return233253 }234254 s, err := t.session(r, body)255+ if err != nil && r.URL.Path != "/events" && r.Header.Get("Authorization") == "" {256+ s, err = t.cookieReadSession(r, s)257+ }235258 if err != nil {236259 writeJSON(w, 401, map[string]string{"error": err.Error()})237260 return@@ -58,6 +58,31 @@ func TestSignedSessionSurvivesNavigationAndRevokes(t *testing.T) {
5858 if err != nil || actor != tenant.Policy().Owner {5959 t.Fatalf("session actor %s %v", actor, err)6060 }61+ // Reads through the query bridge use the session when the request comes62+ // from this origin; foreign origins and publishing still need a signature.63+ query := func(path, origin string) int {64+ req := httptest.NewRequest("POST", "http://relay.test"+path, strings.NewReader(`[{"kinds":[10318],"limit":1}]`))65+ req.Header.Set("Content-Type", "application/json")66+ if origin != "" {67+ req.Header.Set("Origin", origin)68+ }69+ req.AddCookie(cookies[0])70+ res := httptest.NewRecorder()71+ a.ServeHTTP(res, req)72+ return res.Code73+ }74+ if code := query("/query", "http://relay.test"); code != 200 {75+ t.Fatalf("session query %d", code)76+ }77+ if code := query("/query", "https://foreign.test"); code != 401 {78+ t.Fatalf("foreign origin query %d", code)79+ }80+ if code := query("/query", ""); code != 401 {81+ t.Fatalf("query without origin %d", code)82+ }83+ if code := query("/events", "http://relay.test"); code != 401 {84+ t.Fatalf("cookie publish %d", code)85+ }6186 logout := httptest.NewRequest("POST", "http://relay.test/session/logout", nil)6287 logout.AddCookie(cookies[0])6388 logout.Header.Set("Origin", "https://foreign.test")@@ -607,7 +607,7 @@
607607 const select = event.target.closest("select[name=visibility]");608608 if (select) this.change("visibility", select.closest("tr"), select.value).catch(err => this.say("error: " + err.message, true));609609 });610- this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true)));610+ this.load().catch(err => this.say("error: " + err.message, true));611611 }612612 613613 // whenSigned runs once a signer is available; the bridge announces a@@ -624,8 +624,23 @@
624624 else delete this.output.dataset.error;625625 }626626 627+ // load reads with the browser session first so showing the page never628+ // asks the signer; a signed call is the fallback without a session.627629 async load() {628630 this.say("loading…");631+ const response = await fetch(tiny.localPath("/connect.json?catalog=1"), {credentials: "same-origin", headers: {Accept: "application/json"}});632+ if (response.status === 401) {633+ this.whenSigned(() => this.loadSigned().catch(err => this.say("error: " + err.message, true)));634+ return;635+ }636+ if (!response.ok) throw Error("Request failed (" + response.status + ")");637+ const data = await response.json();638+ this.rows = data.connections;639+ this.catalog = data.catalog;640+ this.render();641+ this.say("");642+ }643+ async loadSigned() {629644 [this.rows, this.catalog] = await Promise.all([rpc("listconnections"), rpc("listconnectiontemplates")]);630645 this.render();631646 this.say("");@@ -112,10 +112,10 @@
112112 </details>113113 <h3>Publish signed relay records</h3>114114 <p>These actions open the configured signer and publish owner-authored Nostr records.</p>115-<private-services>115+<private-services pubkey="{{.Actor}}">116116 <h3>Private Git relay list</h3>117117 <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>118-<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>118+<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>119119 <noscript><p>Connect a signer and enable JavaScript to encrypt your private service list.</p></noscript>120120 <output role="status"></output>121121 </private-services>@@ -9,17 +9,33 @@
99 const relay = value => relayURL(value, {strict: true});1010 const mergeRows = (rows, urls) =>1111 rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));12- const publicKey = async () => {12+ const publicKey = async element => {13+ const known = element?.getAttribute("pubkey");14+ if (/^[0-9a-f]{64}$/.test(known || "")) return known;1315 const active = signer();1416 if (active?.getPublicKey) return active.getPublicKey();1517 if (window.tiny?.getPublicKey) return window.tiny.getPublicKey();1618 throw Error("Connect a signer first.");1719 };20+ // readList uses the browser session when the page has one, so showing21+ // the list never asks the signer; a signed query is the fallback.1822 const readList = async pubkey => {1923 const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);20- const response = await window.tiny.signedFetch("/query", "POST", body, {21- contentType: "application/json"22- });24+ let response = null;25+ if (typeof fetch === "function" && window.tiny?.localPath) {26+ response = await fetch(window.tiny.localPath("/query"), {27+ method: "POST",28+ credentials: "same-origin",29+ headers: {"content-type": "application/json"},30+ body31+ });32+ if (response.status === 401) response = null;33+ }34+ if (!response) {35+ response = await window.tiny.signedFetch("/query", "POST", body, {36+ contentType: "application/json"37+ });38+ }2339 if (!response || response.ok === false) throw Error("Could not read the private relay list.");2440 const rows = await response.json();2541 if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");@@ -83,13 +99,14 @@
8399 this.form.querySelector("button").disabled = false;84100 });85101 });86- this.whenSigned(() =>87- this.load()88- .then(() => {89- this.form.querySelector("button").disabled = false;90- })91- .catch(error => this.say("Error: " + error.message, true))92- );102+ this.querySelector("[data-decrypt]")?.addEventListener("click", () => {103+ this.whenSigned(() => this.reveal().catch(error => this.say("Error: " + error.message, true)));104+ });105+ this.load()106+ .then(() => {107+ this.form.querySelector("button").disabled = false;108+ })109+ .catch(error => this.say("Error: " + error.message, true));93110 }94111 95112 // whenSigned runs once a signer is available. The bridge announces a@@ -106,10 +123,24 @@
106123 else delete this.output.dataset.error;107124 }108125 126+ // load reads the published event without decrypting it. Decryption127+ // asks the signer, so it waits for the Decrypt to edit control.109128 async load() {110129 this.say("Reading encrypted list…");111- const pubkey = await publicKey();112- const event = await readList(pubkey);130+ const pubkey = await publicKey(this);131+ this.event = await readList(pubkey);132+ if (!this.event) {133+ this.say("No private relay list published yet.");134+ return;135+ }136+ const control = this.querySelector("[data-decrypt]");137+ if (control) control.hidden = false;138+ this.say("An encrypted list is published. Decrypt to edit it, or publish new URLs to merge them.");139+ }140+141+ async reveal() {142+ const pubkey = await publicKey(this);143+ const event = this.event || (await readList(pubkey));113144 if (!event) {114145 this.say("No private relay list published yet.");115146 return;@@ -132,7 +163,7 @@
132163 throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");133164 const urls = rawURLs.map(relay);134165 const unique = [...new Set(urls)];135- const pubkey = await publicKey();166+ const pubkey = await publicKey(this);136167 const existing = await readList(pubkey);137168 let rows = [];138169 if (existing) {@@ -397,7 +397,18 @@ func (a *App) connectJSON(writer http.ResponseWriter, request *http.Request) {
397397 writeJSON(writer, http.StatusNotFound, map[string]string{"error": err.Error()})398398 return399399 }400- writeJSON(writer, http.StatusOK, result)400+ if request.URL.Query().Get("catalog") == "" {401+ writeJSON(writer, http.StatusOK, result)402+ return403+ }404+ // The Connect page reads the list and the card catalog with its session405+ // so showing the page never asks the signer for a signature.406+ catalog, err := a.backend.Query(request.Context(), "listconnectiontemplates", nil, actor)407+ if err != nil {408+ writeJSON(writer, http.StatusNotFound, map[string]string{"error": err.Error()})409+ return410+ }411+ writeJSON(writer, http.StatusOK, map[string]any{"connections": result, "catalog": catalog})401412 }402413 403414 func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {