eleven / ocho Public

home+web: make unlocked space links restore smoothly (#1058)

The regression was introduced on 2026-07-12 by b04215ad (#732), when signed-out bare instance visits began redirecting immediately to home. That was correct for ordinary visits, but home's unlocked space list still linked to each bare origin. In a clean browser those links therefore hit the new pre-paint redirect before the instance could generate the ephemeral key required to start /restore, sending the member straight back to the home list forever.

Mark links from the unlocked vault as one-shot restores. The instance exempts that marker from the pre-paint bounce, consumes it, generates its ephemeral key, and launches the existing encrypted handoff. Because the navigation remains in the same tab, /restore can reuse home's origin-scoped token and DEK from sessionStorage instead of asking for the passkey twice. Stale sessions fall back to the normal ceremony, while app and CLI exports deliberately keep explicit passkey authentication.
Co-authored-by: ChatGPT <noreply@openai.com>
James Adam ff11ba1c4bbda02a05ccf2e092ac74fac501859f parent 7c5cb67
9 files changed, +123 −30
  • changelog/2026-07-16-smooth-space-sign-in.md +1 −0
  • home/test/serverlist.test.mjs +7 −0
  • home/web/static/home.js +5 −2
  • home/web/static/restore.js +58 −23
  • home/web/static/serverlist.js +13 −0
  • test/backup.test.mjs +8 −1
  • web/pages/index.html +4 −2
  • web/static/backup.js +6 −0
  • web/static/shell.js +21 −2
diff --git a/changelog/2026-07-16-smooth-space-sign-in.md b/changelog/2026-07-16-smooth-space-sign-in.md
new file mode 100644
index 0000000..64f6f06
--- /dev/null
+++ b/changelog/2026-07-16-smooth-space-sign-in.md
@@ -0,0 +1 @@
+🤖 Opening a space from your unlocked Eleven home now signs you straight in, without bouncing back home or asking for your passkey again.
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs
index 6e94ed3..e35fb4b 100644
--- a/home/test/serverlist.test.mjs
+++ b/home/test/serverlist.test.mjs
@@ -5,6 +5,7 @@ import assert from "node:assert/strict";
import {
partitionServers,
partitionArchived,
+ spaceRestoreURL,
setArchived,
toggleStar,
moveInGroup,
@@ -32,6 +33,12 @@ async function test(name, fn) {
const urls = (list) => list.map((s) => s.url);
+await test("space links request automatic restore and preserve their query", () => {
+ assert.equal(spaceRestoreURL("https://chat.example.com"), "https://chat.example.com/?restore=1");
+ assert.equal(spaceRestoreURL("https://chat.example.com/?thread=7"), "https://chat.example.com/?thread=7&restore=1");
+ assert.equal(spaceRestoreURL("not a url"), "not a url");
+});
+
await test("partitionServers splits starred from the rest, keeping array order", () => {
const servers = [
{ url: "a", starred: true },
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 1811a6d..80eff93 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -23,7 +23,7 @@ import {
} from "/static/backup-core.js";
import { friendlyCeremonyError, formatCeremonyError } from "/static/ceremony-errors.js";
import {
- partitionServers, partitionArchived, setArchived,
+ partitionServers, partitionArchived, setArchived, spaceRestoreURL,
toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold,
addTombstone, clearTombstone, mergeRemoved,
} from "/static/serverlist.js";
@@ -483,7 +483,10 @@ function serverRow(s, sortable) {
main.className = "srv-main";
const name = document.createElement("a");
name.className = "name";
- name.href = s.url;
+ // A clean browser has no per-space local session yet. The marker asks the
+ // destination to begin its encrypted restore handoff instead of bouncing
+ // straight back here; restore.js reuses this tab's unlocked home session.
+ name.href = spaceRestoreURL(s.url);
name.textContent = labelOf(s);
const url = document.createElement("span");
url.className = "url";
diff --git a/home/web/static/restore.js b/home/web/static/restore.js
index 1a1e745..4dc2531 100644
--- a/home/web/static/restore.js
+++ b/home/web/static/restore.js
@@ -12,7 +12,7 @@
// sends us a ret we don't have in the backup gets nothing back.
import {
- b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, unpackBlob, openBlob, wrapToPublicKey,
+ b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, importDEK, unpackBlob, openBlob, wrapToPublicKey,
} from "/static/crypto.js";
import { beginLogin, runLogin, fetchBlob } from "/static/backup-core.js";
import { formatCeremonyError } from "/static/ceremony-errors.js";
@@ -70,30 +70,65 @@ function prefetch() {
return beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
}
+// A click in home's decrypted space list stays in the same tab, so home's
+// origin-scoped sessionStorage survives the round trip home → space → /restore.
+// Reusing its token + DEK makes that click one smooth handoff without another
+// passkey prompt. Both are still browser-only secrets; the server sees neither
+// the DEK nor any decrypted vault content. A stale session simply falls back to
+// the normal ceremony below.
+async function unlockedHomeSession() {
+ const token = sessionStorage.getItem("home_token") || "";
+ const rawDEK = sessionStorage.getItem("home_dek") || "";
+ if (!token || !rawDEK) return null;
+ try {
+ const dek = await importDEK(rawDEK);
+ const got = await fetchBlob(token);
+ if (!got) return null;
+ // Validate the saved DEK now and let the instance restore reuse the opened
+ // vault instead of decrypting it twice.
+ const opened = await openBlob(got.bytes, dek);
+ return { token, dek, got, opened };
+ } catch {
+ return null;
+ }
+}
+
async function restore() {
$("restore").disabled = true;
- setStatus("Confirming it’s you…", "busy");
- const { token, credId, prf } = await runLogin(loginBegin);
- if (!prf) {
- setStatus("Signed in, but this browser can't unlock your spaces here. Try another browser.", "error");
- $("restore").disabled = false;
- return;
- }
- const got = await fetchBlob(token);
- if (!got) {
- setStatus("Nothing saved for this sign-in yet.", "error");
- $("restore").disabled = false;
- return;
- }
- const kek = await kekFromPRF(prf);
- const { keys } = unpackBlob(got.bytes);
- const wrapped = keys[credId];
- if (!wrapped) {
- setStatus("That sign-in can't unlock these spaces.", "error");
- $("restore").disabled = false;
- return;
+
+ let token, dek, got, opened;
+ // Only an origin-bound instance restore may reuse the unlocked list. App and
+ // CLI payloads export the whole home session, so they always retain their
+ // explicit passkey ceremony and fixed-target security boundary.
+ const unlocked = payload && payload.ret ? await unlockedHomeSession() : null;
+ if (unlocked) {
+ ({ token, dek, got, opened } = unlocked);
+ setStatus("Unlocked. Opening your chat…", "busy");
+ } else {
+ setStatus("Confirming it’s you…", "busy");
+ const login = await runLogin(loginBegin);
+ token = login.token;
+ if (!login.prf) {
+ setStatus("Signed in, but this browser can't unlock your spaces here. Try another browser.", "error");
+ $("restore").disabled = false;
+ return;
+ }
+ got = await fetchBlob(token);
+ if (!got) {
+ setStatus("Nothing saved for this sign-in yet.", "error");
+ $("restore").disabled = false;
+ return;
+ }
+ const kek = await kekFromPRF(login.prf);
+ const { keys } = unpackBlob(got.bytes);
+ const wrapped = keys[login.credId];
+ if (!wrapped) {
+ setStatus("That sign-in can't unlock these spaces.", "error");
+ $("restore").disabled = false;
+ return;
+ }
+ dek = await unwrapDEK(wrapped, kek);
}
- const dek = await unwrapDEK(wrapped, kek);
// App handoff: hand the SESSION (link token + blob key) to the native app,
// ECIES-wrapped to the ephemeral public key it generated — nothing is ever
@@ -152,7 +187,7 @@ async function restore() {
});
return;
}
- const { obj } = await openBlob(got.bytes, dek);
+ const { obj } = opened || await openBlob(got.bytes, dek);
// Only the entry whose origin matches the requester (ret) is ever returned.
const host = new URL(payload.ret).host;
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js
index 443f4a8..44d71ec 100644
--- a/home/web/static/serverlist.js
+++ b/home/web/static/serverlist.js
@@ -10,6 +10,19 @@
// This is the contract the native apps read too: starred spaces populate the rail,
// the rest live behind a "more" affordance, and archived ones stay out of view.
+// spaceRestoreURL marks a home-list navigation as an automatic sign-in. The
+// instance consumes the marker before creating its ephemeral restore key, then
+// returns through home's encrypted handoff. Existing query parameters survive.
+export function spaceRestoreURL(raw) {
+ try {
+ const url = new URL(raw);
+ url.searchParams.set("restore", "1");
+ return url.toString();
+ } catch {
+ return raw;
+ }
+}
+
// partitionServers splits into { starred, others }, each in array order. Callers
// pass the ACTIVE subset (archived entries filtered out via partitionArchived).
export function partitionServers(servers) {
diff --git a/test/backup.test.mjs b/test/backup.test.mjs
index c14bf37..276950e 100644
--- a/test/backup.test.mjs
+++ b/test/backup.test.mjs
@@ -1,6 +1,6 @@
// Tests for the passkey-backup banner visibility rule. node test/backup.test.mjs
import assert from "node:assert/strict";
-import { backupBannerState, parseReturnNonce, parseRestoreReturn, REMIND_RUNGS_MS } from "../web/static/backup.js";
+import { autoRestoreRequested, backupBannerState, parseReturnNonce, parseRestoreReturn, REMIND_RUNGS_MS } from "../web/static/backup.js";
let failures = 0;
async function test(name, fn) {
@@ -16,6 +16,13 @@ async function test(name, fn) {
const DAY = 24 * 60 * 60 * 1000;
const now = 1_700_000_000_000; // a fixed "now" so snooze comparisons are deterministic
+await test("home space links request automatic restore by exact marker", () => {
+ assert.equal(autoRestoreRequested("?restore=1"), true);
+ assert.equal(autoRestoreRequested("?thread=7&restore=1"), true);
+ assert.equal(autoRestoreRequested("?restore=0"), false);
+ assert.equal(autoRestoreRequested("?restored=1"), false);
+});
+
await test("not enrolled + not snoozed → show", () => {
assert.equal(backupBannerState({ enrolled: false, dismissed: false, snoozedUntil: 0, now }), "show");
});
diff --git a/web/pages/index.html b/web/pages/index.html
index d52602a..be18337 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -39,7 +39,8 @@
the same check as a backstop (corrupt session, storage denied). Stays put:
native webviews (__lchatEmbed, injected at documentStart), the installed
Home-Screen app (paste-to-join is its only way to consume an invite
- link), ?stay=1, restore returns from home, and unsubstituted dev pages. -->
+ link), ?stay=1, home's ?restore=1 handoff, restore returns from home, and
+ unsubstituted dev pages. -->
<script nonce="__CSP_NONCE__">
(function () {
try {
@@ -47,7 +48,8 @@
if (matchMedia("(display-mode: standalone)").matches || navigator.standalone === true) return;
if (localStorage.getItem("lchat_session")) return;
if (location.hash.indexOf("lchat_restore=") !== -1) return;
- if (new URLSearchParams(location.search).has("stay")) return;
+ var params = new URLSearchParams(location.search);
+ if (params.has("stay") || params.get("restore") === "1") return;
var home = window.__homeOrigin || "";
if (home.indexOf("http") === 0) location.replace(home);
} catch (e) {} /* storage denied etc.: route() decides after boot */
diff --git a/web/static/backup.js b/web/static/backup.js
index f641506..2f8ef7f 100644
--- a/web/static/backup.js
+++ b/web/static/backup.js
@@ -396,6 +396,12 @@ export function checkEnrolReturn() {
// ---- restore (get back in on a fresh browser) --------------------------------
+// autoRestoreRequested recognises links from home's unlocked space list. Keep
+// this exact-value check in sync with the tiny pre-paint gate in index.html.
+export function autoRestoreRequested(search) {
+ return new URLSearchParams(search || "").get("restore") === "1";
+}
+
// canRestore: offer restore only when a home server is configured and we're in a
// real browser. In the native wrapper the passkey ceremony can't run in the
// webview (native restore is a separate, later flow), so we don't offer it there.
diff --git a/web/static/shell.js b/web/static/shell.js
index b3af3b0..3bff5d7 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -157,7 +157,7 @@ import { openLightbox } from "/static/lightbox.js";
import { mutedPeople, blockedPeople, loadModPrefs, setPersonMuted, openReportSheet, loadBlocks, setPersonBlocked, onModerationChange } from "/static/moderation.js";
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
-import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js";
+import { initBackupBanner, canRestore, autoRestoreRequested, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js";
import {
needsHomeScreen,
pushAvailable,
@@ -958,9 +958,18 @@ if (!embed && !session && location.hash.includes("lchat_restore=")) {
route();
}
+function consumeAutoRestoreMarker() {
+ const clean = new URL(location.href);
+ clean.searchParams.delete("restore");
+ history.replaceState(null, "", clean.pathname + clean.search + clean.hash);
+}
+
function route() {
if (embed) return bootEmbed();
if (!session) return showNoInvite();
+ // An already-signed-in space needs no handoff, but still consumes home's
+ // one-shot marker so signing out later cannot unexpectedly trigger one.
+ if (autoRestoreRequested(location.search)) consumeAutoRestoreMarker();
if (needsHomeScreen() && localStorage.getItem("lchat_a2hs") !== "done") {
return showA2HS(boot);
}
@@ -7900,7 +7909,17 @@ function showNoInvite() {
const standalone =
matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
const home = window.__homeOrigin || "";
- if (!standalone && home.startsWith("http") && !new URLSearchParams(location.search).has("stay")) {
+ const params = new URLSearchParams(location.search);
+ if (autoRestoreRequested(location.search) && canRestore()) {
+ // Links from an unlocked home vault land here first so this origin can
+ // generate and retain the ephemeral private key. Consume the marker before
+ // leaving; if crypto/storage fails, recursion paints the ordinary sign-in
+ // card rather than trapping the tab in an automatic retry loop.
+ consumeAutoRestoreMarker();
+ launchRestore().catch(() => showNoInvite());
+ return;
+ }
+ if (!standalone && home.startsWith("http") && !params.has("stay")) {
return location.replace(home);
}
hideLoading();