From 1385ecb56c7ca04886048cce4382783970325d69 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 26 Sep 2026 21:37:12 +0300 Subject: [PATCH] feat: pin a server from onboarding, derived from the address --- web/index.html | 14 ++++++---- web/js/app.js | 71 ++++++++++++++++++++++++++++++++++++++++++-------- web/style.css | 11 ++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/web/index.html b/web/index.html index 54197c1..4e06cc6 100644 --- a/web/index.html +++ b/web/index.html @@ -39,12 +39,16 @@

Identity is one 32-byte seed. Back it up: it is the only secret, and losing it means losing the address.

- or restore from a saved seed: - - -
+

or restore from a saved seed:

+
+ + + + +
diff --git a/web/js/app.js b/web/js/app.js index fd4b72b..813e2a8 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -167,23 +167,48 @@ $("restore-identity").onclick = async () => { return status(err.message, true); } showApp(); - // The address field is optional: restoring must succeed on its own even if - // this fails (server not pinned yet, offline, typo) — recall can always be - // retried from settings afterward. + // The key/address fields are both optional, and neither can block the + // identity restore that already succeeded above — a typo, an offline server + // or an unregistered address just leaves that one step to retry from + // settings afterward. No separate host field: it's already the part of the + // address after the @, so asking for it twice would just be one more place + // to enter it wrong (exactly how "pinned randogoth@smol.place" happened — + // a whole address typed into what was meant to be a bare host). Pinning has + // to happen before recall is attempted, since recall requires a pin. + const keyText = $("restore-key").value.trim(); const addressText = $("restore-address").value.trim(); - let message = "identity restored.", warn = false; + const lines = ["identity restored."]; + let warn = false; + + if (keyText) { + if (!addressText) { + lines.push("enter the address above too — its server is derived from the part after the @."); + warn = true; + } else { + try { + const host = parseAddress(addressText).host; + pinServerFromText(host, keyText); + lines.push(`pinned ${host}.`); + } catch (err) { + lines.push(err.message); + warn = true; + } + } + } + if (addressText) { try { const addr = await recallAccount(addressText); - message += `\nrecalled ${addr.short}`; + lines.push(`recalled ${addr.short}.`); renderAccount(); renderSettings(); } catch (err) { - message += `\ncouldn't recall that address: ${err.message}`; + lines.push(`couldn't recall that address: ${err.message}`); warn = true; } } - status(message, warn); + + status(lines.join("\n"), warn); }; // --- header --------------------------------------------------------------------- @@ -852,14 +877,32 @@ function renderSettings() { ].filter(node => node != null)); } +// Shared by the settings pin form and the optional host/key fields on setup — +// same validation, same unconditional pin. Both are direct, deliberate entry +// of a specific key (unlike importData's conflict-averse merge), so overwrite +// is the expected behavior here, not something to guard against. +function pinServerFromText(hostText, keyText) { + const host = hostText.trim().toLowerCase(); + // A pin is keyed by bare host — connect() strips the port before looking one + // up (parseAddress splits user@host:port into three), so "user@host" or + // "host:port" here would silently pin a string nothing ever looks up again, + // exactly the mistake that produced "pinned
" followed by a + // confusing "no pinned key" from recall right after. + if (host.includes("@")) + throw new SmolError(`"${host}" looks like an address, not a server — the part after the @ goes here`); + if (host.includes(":")) + throw new SmolError(`"${host}": a pin is by host only, no port — just the part before the colon`); + const key = b32decode(keyText.trim()); + if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`); + store.pinServer(host, key); + return host; +} + $("pin-form").onsubmit = (event) => { event.preventDefault(); const form = event.target; try { - const host = form.host.value.trim().toLowerCase(); - const key = b32decode(form.key.value.trim()); - if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`); - store.pinServer(host, key); + const host = pinServerFromText(form.host.value, form.key.value); status(`pinned ${host}`); renderSettings(); form.reset(); @@ -1034,6 +1077,12 @@ function applyPresetServer() { } catch { /* malformed preset: leave unpinned rather than block boot */ } } +// A preset deployment already knows its own server, so asking again on the +// setup screen would be redundant — and confusing in the other direction +// ("why do I need to type this if it's supposed to be automatic?"). A generic +// deployment has no preset, so this stays the only way to pin from onboarding. +if (PRESET_SERVER) $("restore-key-label").hidden = true; + applyPresetServer(); applyTheme(); store.identity() ? showApp() : showSetup(); diff --git a/web/style.css b/web/style.css index 8b93509..ecfdff9 100644 --- a/web/style.css +++ b/web/style.css @@ -324,6 +324,17 @@ main { flex: 1; min-height: 0; display: flex; background: var(--bg); } .setup h2 { margin: 0; font-weight: 400; } .setup p { color: var(--muted); max-width: 480px; } .setup .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; } +.setup .row.restore-row { align-items: flex-end; } +.setup label { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 150px; + font-size: 12px; + color: var(--muted); + text-align: left; +} +.setup label input { font-family: var(--mono); font-size: 13px; } /* --- dialogs --------------------------------------------------------------------- */