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 --------------------------------------------------------------------- */