feat: pin a server from onboarding, derived from the address

This commit is contained in:
randogoth 2026-09-26 21:37:12 +03:00
parent 3576e48b99
commit 1385ecb56c
3 changed files with 80 additions and 16 deletions

View file

@ -39,12 +39,16 @@
<p>Identity is one 32-byte seed. Back it up: it is the only secret, and losing it means losing the address.</p>
<div class="row">
<button id="create-identity">create a new identity</button>
<span>or restore from a saved seed:</span>
<input id="restore-seed" placeholder="64 hex characters" spellcheck="false">
<input id="restore-address" placeholder="alice@example.org (if already registered)"
spellcheck="false">
<button id="restore-identity">restore</button>
</div>
<p class="hint">or restore from a saved seed:</p>
<div class="row restore-row">
<label>seed<input id="restore-seed" placeholder="64 hex characters" spellcheck="false"></label>
<label>address (optional)<input id="restore-address" placeholder="alice@example.org, if already registered"
spellcheck="false"></label>
<label id="restore-key-label">server's public key
<input id="restore-key" placeholder="base32" spellcheck="false"></label>
<button id="restore-identity">restore</button>
</div>
</main>
<main id="app" hidden>

View file

@ -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 <address>" 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();

View file

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