eleven / messenger Public

web,home: the create-from-home journey stops re-pitching passkeys (#378)

Two seams from the first real run of slide-to-create, both rooted in the
same fact: the vault saves the space's ADDRESS at creation, but the
space's KEY is born in the instance's browser when the setup link is
claimed (origins are isolated — by design), so the enrol round-trip is
still needed. The journey now acknowledges that instead of treating the
creator as a stranger:

- home appends ?src=home to the setup link (query, never the fragment —
  the secret rides alone); join.js remembers the hint on the instance
  origin; a new bridge.fromHome steers the setup conversation's key step
  to a tailored variant: "This space is locked with a key that was just
  created here… Your Eleven sign-in doesn't hold it yet. Add it to your
  passkey?" with an "Add to my passkey" chip. Mechanics unchanged (same
  enrol popup, one Face ID); pure copy/routing. A hint only — nothing
  grants access from it.

- The enrol popup no longer asks "overwrite?" when the existing vault
  entry is the create flow's address-only stub: there are no credentials
  to clobber, so it merges silently — upgrading the stub with the
  space's key and its real name. The replace confirmation still guards
  entries that HAVE credentials (the anti-clobber case it exists for:
  anyone can open the popup with a junk fragment for a known host).
  upsert now replaces in place, preserving list position + starred.

New spaces pick this up via the control-binary swap (add-instance stages
the running build); existing instances lag until their next deploy,
which only affects re-running setup there — nothing member-visible.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d93b340324dd4019ee41298f2b559840c9e61ceb parent 27e88ae
5 files changed, +72 −11
  • home/web/static/enroll.js +17 −10
  • home/web/static/home.js +5 −1
  • web/static/apps/setup.flow.js +37 −0
  • web/static/join.js +8 −0
  • web/static/shell.js +5 −0
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
index c5ff81b..a930c7b 100644
--- a/home/web/static/enroll.js
+++ b/home/web/static/enroll.js
@@ -84,10 +84,14 @@ function goBack(withNonce) {
// ---- merge one entry into the (possibly new) blob ----------------------------
-// Upsert the entry into a servers list by url (last-writer-wins per url).
+// Upsert the entry into a servers list by url (last-writer-wins per url),
+// replacing IN PLACE — enrolment must never shuffle a curated list, and the
+// starred flag survives the upgrade.
function upsert(servers, entry) {
- const list = (servers || []).filter((s) => s.url !== entry.url);
- list.push(entry);
+ const list = (servers || []).slice();
+ const i = list.findIndex((s) => s.url === entry.url);
+ if (i < 0) return [...list, entry];
+ list[i] = list[i].starred ? { ...entry, starred: true } : entry;
return list;
}
@@ -188,15 +192,18 @@ async function addWithLogin({ token, credId, prf }) {
}
const dek = await unwrapDEK(wrapped, kek);
- // If a DIFFERENT entry already exists for this host, adding would overwrite
- // the credentials the user already trusts. Anyone can open this page with an
- // arbitrary fragment, so a page that knows the host could try to clobber a
- // real saved entry with junk — a silent add wouldn't reveal that.
- // Require an explicit, host-specific replace confirmation. An identical
- // re-save (same credentials) is a harmless no-op and skips the prompt.
+ // If an entry with DIFFERENT credentials already exists for this host, adding
+ // would overwrite the credentials the user already trusts. Anyone can open
+ // this page with an arbitrary fragment, so a page that knows the host could
+ // try to clobber a real saved entry with junk — a silent add wouldn't reveal
+ // that. Require an explicit, host-specific replace confirmation.
+ // Two cases merge silently instead: an identical re-save (harmless no-op),
+ // and an existing entry with NO credentials — the address-only stub the
+ // create-a-space flow saves. There's nothing to clobber; enrolment upgrades
+ // the stub with this space's key (and its real name).
let cur = got, opened = await openBlob(got.bytes, dek);
const existing = (opened.obj.servers || []).find((s) => s.url === payload.entry.url);
- if (existing && credFingerprint(existing) !== credFingerprint(payload.entry)) {
+ if (existing && existing.credentials && credFingerprint(existing) !== credFingerprint(payload.entry)) {
const ok = await confirmReplace(existing);
if (!ok) {
// The replace prompt replaced the action buttons, so this is terminal:
diff --git a/home/web/static/home.js b/home/web/static/home.js
index b981568..64585ea 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -508,7 +508,11 @@ function showNewSpaceResult(host, setupLink) {
if (setupLink) {
const a = document.createElement("a");
a.className = "btn-link";
- a.href = setupLink;
+ // ?src=home tells the space's setup flow this person already holds an
+ // Eleven sign-in, so its key step says "add to your passkey" instead of
+ // pitching passkeys from scratch. Query param, not fragment: the secret
+ // stays alone in the fragment. (Query never carries anything sensitive.)
+ a.href = setupLink.replace("/join/setup#", "/join/setup?src=home#");
a.target = "_blank";
a.rel = "noopener";
a.textContent = "Set up your space →";
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js
index edc5596..e03cf74 100644
--- a/web/static/apps/setup.flow.js
+++ b/web/static/apps/setup.flow.js
@@ -179,6 +179,10 @@ export default {
sayIfNew: "Your key is safely backed up behind your passkey. ✅",
goto: "finish",
},
+ // Someone who created this space from their Eleven sign-in page already
+ // holds a passkey — don't pitch passkeys from scratch, offer to add
+ // this space's key to theirs (keys-home).
+ { if: "bridge.fromHome", goto: "keys-home" },
],
bubbles: [
{
@@ -199,6 +203,39 @@ export default {
{ label: "Not now", spent: "Skipped", goto: "keys-confirm" },
],
},
+ {
+ // The from-home variant of "keys": they signed in with Eleven to create
+ // this space, so the ask is one addition, not a new concept. Mechanics
+ // are identical (the enrol popup signs into their existing passkey and
+ // merges this space's key + name over the address-only stub).
+ id: "keys-home",
+ entries: [
+ {
+ if: "bridge.enrolled",
+ set: { "vars.backedUp": true },
+ recordIfNew: { label: "Saved your credentials securely with a passkey", value: "passkey" },
+ sayIfNew: "Your key is safely backed up behind your passkey. ✅",
+ goto: "finish",
+ },
+ ],
+ bubbles: [
+ {
+ text:
+ "Last thing — the key. 🔑\n\n" +
+ "This space is locked with a key that was just created here, in this browser. Your Eleven sign-in doesn't hold it yet — so if this browser is lost, the space goes with it.",
+ },
+ { text: "Add it to your passkey? One tap — you'll be brought straight back." },
+ ],
+ chips: [
+ {
+ label: "Add to my passkey",
+ advancing: false,
+ effect: { verb: "savePasskey" },
+ probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" },
+ },
+ { label: "Not now", spent: "Skipped", goto: "keys-confirm" },
+ ],
+ },
{
id: "keys-waiting",
bubbles: [{ text: "I'll wait here while you finish saving the passkey. When you're done, tap Continue." }],
diff --git a/web/static/join.js b/web/static/join.js
index 035935c..76b4f4e 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -26,6 +26,14 @@ const isStandalone =
const { T, name: fragName } = parseInviteFragment(location.hash.slice(1));
+// ?src=home marks a link opened from the person's Eleven sign-in page (the
+// create-a-space flow). Remember it on this origin so the setup conversation's
+// key step can say "add to your passkey" instead of pitching passkeys from
+// scratch. A hint only — nothing downstream trusts it for access.
+if (new URLSearchParams(location.search).get("src") === "home") {
+ try { localStorage.setItem("lchat_from_home", "1"); } catch { /* storage denied: the generic copy shows */ }
+}
+
// Single-fire guard for the claim: whatever path calls it (button, double tap,
// Enter), only the first attempt proceeds; the rest are ignored.
let claimStarted = false;
diff --git a/web/static/shell.js b/web/static/shell.js
index 93ab96f..70cbeeb 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -2993,6 +2993,11 @@ setSetupBridge({
},
savePasskey: () => launchEnrol(session),
enrolled: () => isEnrolled(),
+ // Did this person arrive via their Eleven sign-in page (create-a-space)? Set
+ // by join.js from the link's ?src=home; steers the key step's copy only.
+ fromHome: () => {
+ try { return localStorage.getItem("lchat_from_home") === "1"; } catch { return false; }
+ },
// Skipping the key ceremony starts the banner's escalating reminder schedule
// (24h → 72h → 1w) instead of an immediate contradiction of the conversation.
deferBackupReminders: () => deferBackupNudges(),