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>
5 files changed,
+72
−11
home/web/static/enroll.js+17 −10home/web/static/home.js+5 −1web/static/apps/setup.flow.js+37 −0web/static/join.js+8 −0web/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(), |