// The home landing page: every sign-in method is a door into the same // anonymous account, and the card composes itself per browser (#1112, as // revised by Paul 2026-07-19): exactly ONE door — the last-used method when // this browser holds one, else the platform default — then a quiet "Other // ways to sign in" link to the all-ways step, which lists every door // (email included; nothing else ever sits on the main card). Apple and // email are find-or-create (one button resolves your account or mints it, // no sign-in vs sign-up split). // Create-a-passkey appears only after a failed ceremony (the #newhere // explainer — a cancelled prompt and a missing credential are deliberately // indistinguishable, NotAllowedError either way, so the failure moment serves // both readings). All crypto is client-side (crypto.js); the WebAuthn // ceremonies + blob I/O live in backup-core.js. Status renders inline — // never a toast. // // The identity-vs-key split, made visible: an Apple or email sign-in proves // who you are (siwa.go / email.go answer with a device link token, exactly // like a passkey ceremony) but can never decrypt — the vault blob only opens // under a passkey's PRF-derived KEK. So an anchor sign-in lands signed in // either empty (fresh account, no blob yet) or sealed, with a quiet // unlock-with-your-passkey prompt. "Sign in" is the friendly name for // unlocking the encrypted key vault; the server only ever holds opaque // ciphertext, never a usable key. import { kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK, sealBlob, unpackBlob, openBlob, } from "/static/crypto.js"; import { apiJSON, beginLogin, beginRegister, runLogin, runRegister, fetchBlob, putBlob, } from "/static/backup-core.js"; import { friendlyCeremonyError, formatCeremonyError } from "/static/ceremony-errors.js"; import { partitionServers, partitionArchived, setArchived, spaceRestoreURL, toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold, addTombstone, clearTombstone, mergeRemoved, } from "/static/serverlist.js"; import { mountFader } from "/static/fader.js"; import { parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash } from "/static/joinreturn.js"; import { startHomeLoader, finishHomeLoader } from "/static/bootscene.js"; import { BANDS, bandName, bandsOffered, tierForBand, regionCurrency, approxLocal, capNote } from "/static/fx.js"; // ---- session state ---------------------------------------------------------- // The identity-vs-key split, applied to persistence (stay signed in, Option // A): the link TOKEN outlives the tab — it also lives in localStorage with a // 30-day rolling expiry, so a cold open lands signed in — but the DEK never // does: it stays sessionStorage-only, so closing the tab always re-seals the // list and a stolen disk yields membership, never plaintext. Cold opens land // on the sealed-vault prompt; the passkey ceremony stays the only way in. const state = { get token() { return sessionStorage.getItem("home_token") || ""; }, set token(v) { sessionStorage.setItem("home_token", v); }, get dek() { return sessionStorage.getItem("home_dek") || ""; }, set dek(v) { sessionStorage.setItem("home_dek", v); }, get credId() { return sessionStorage.getItem("home_cred") || ""; }, set credId(v) { sessionStorage.setItem("home_cred", v); }, }; let vault = null; // { obj, keys, version } once unlocked let dekKey = null; // the imported CryptoKey // The persisted session, two localStorage keys: home_session (JSON {token, // method}) and home_session_expires (plain epoch-ms, a separate key so the // inline pre-paint gate can check it without JSON parsing). The expiry ROLLS: // any boot that accepts the session re-persists, buying another 30 days. const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; function persistSession(token, method) { try { localStorage.setItem("home_session", JSON.stringify({ token, method })); localStorage.setItem("home_session_expires", String(Date.now() + SESSION_TTL_MS)); } catch { /* private mode — the sign-in stays tab-only, as before */ } } function clearPersistedSession() { try { localStorage.removeItem("home_session"); localStorage.removeItem("home_session_expires"); } catch { /* nothing to clear */ } } // persistedSession reads the stored {token, method} while it's still live — // null when absent, expired, or unreadable. Pure read; the boot path bumps // the expiry only once the token has actually been accepted. function persistedSession() { try { const exp = Number(localStorage.getItem("home_session_expires") || ""); const s = JSON.parse(localStorage.getItem("home_session") || "null"); return s && s.token && exp > Date.now() ? s : null; } catch { return null; } } // ---- tiny DOM helpers --------------------------------------------------------- const $ = (id) => document.getElementById(id); function setStatus(msg, kind) { const el = $("status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } function vaultStatus(msg, kind) { const el = $("vault-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } function spaceStatus(msg, kind) { const el = $("new-space-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } function archiveStatus(msg, kind) { const el = $("archive-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } // hostOf is the display host for a saved url, tolerant of a non-URL value. function hostOf(url) { try { return new URL(url).host; } catch { return url || ""; } } function labelOf(s) { return s.name || hostOf(s.url); } // fmtBytes renders a storage reading for members: decimal units (matching how // storage is priced), one decimal for GB, whole numbers below. function fmtBytes(n) { if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + " GB"; if (n >= 1e6) return Math.round(n / 1e6) + " MB"; return "<1 MB"; } // Whether this home server offers self-serve space creation (set from // /api/features). The create-a-space controls only appear when it's both // enabled here and you're signed in. let spacesEnabled = false; // provisioned: host → row from GET /api/spaces — which listed spaces THIS // account created, plus their entitlement. billingEnabled mirrors the // provisioner's flag; both drive the quiet Upgrade affordance + Supporter // badge. null = not fetched yet (fetched lazily, once per sign-in). let provisioned = null; let billingEnabled = false; let billingTiers = []; // the provisioner's price book: [{key, name, label}] // A checkout round trip lands back here with ?upgraded=; consumed (once) // by the first showVault after return. let upgradedHost = new URLSearchParams(location.search).get("upgraded") || ""; if (upgradedHost) history.replaceState(null, "", location.pathname + location.hash); // A pay-first create lands back with ?paidcreate=&pcregion= // (Stripe fills the session id into the template). Consumed by the first // vault render after sign-in, which claims the space into existence; a // localStorage marker backs it up so a closed tab can still finish later. let paidCreateSession = new URLSearchParams(location.search).get("paidcreate") || ""; let paidCreateRegion = new URLSearchParams(location.search).get("pcregion") || ""; if (paidCreateSession) history.replaceState(null, "", location.pathname + location.hash); // A space's own menu deep-links here as #upgrade=; consumed once the // provisioner ledger is in hand (before sign-in it just waits in the fragment). // Optional &band= and &tier= ride along (the space menu doesn't // send them, but shared links and the setup conversation's plan chip do). let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)((?:&(?:band=[a-z]+|tier=[a-z0-9_-]+))*)$/) || [])[1] || "").toLowerCase(); // The conversation's chosen tier (#1374): highlights that card in the picker — // a nudge, never a commitment; every card stays one tap from checkout. let tierLink = ((location.hash.match(/[#&]tier=([a-z0-9_-]+)/) || [])[1] || "").toLowerCase(); // A shared link pre-selects an honor band (docs/pricing-bands.md § The // picker — "click a link, get the lower price, instantly"): #band=reduced, // ?band=reduced, or riding an #upgrade fragment. The server honours whatever // band arrives; this only pre-selects the picker. Unknown names are ignored. let bandLink = (() => { const m = location.hash.match(/[#&]band=([a-z]+)/) || location.search.match(/[?&]band=([a-z]+)/); const b = m ? m[1] : ""; return BANDS.some((x) => x.key === b) ? b : ""; })(); // A demo space's countdown banner deep-links here as #claim=; waits in // the fragment through sign-in — a demo needs no account, so its claimant // often has to create their sign-in first, and the claim resumes after. let claimLinkHost = ((location.hash.match(/^#claim=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase(); // A join page sends its visitor here to sign in FIRST (#joinret= — the // signup-first join flow). The return URL parks in sessionStorage so it // survives the anchor doors' round trips (Apple/Google leave this page and // come back via their trampolines, dropping the hash), and the first vault // render after sign-in sends the person straight back to finish joining // instead of showing the space list. Guarded both ends: joinreturn.js pins // the destination to an https /join/ path. { const jr = parseJoinReturn(location.hash); if (jr && validJoinReturn(jr)) { try { sessionStorage.setItem("home_join_return", stashJoinReturn(jr, Date.now())); } catch { /* no storage — plain sign-in */ } history.replaceState(null, "", location.pathname + location.search); } } function joinReturnURL() { try { return readJoinReturnStash(sessionStorage.getItem("home_join_return") || "", Date.now()); } catch { return ""; } } // consumeJoinReturn: the redirect back. True = navigating; callers stop rendering. function consumeJoinReturn() { const url = joinReturnURL(); if (!url) return false; try { sessionStorage.removeItem("home_join_return"); } catch {} location.replace(url); return true; } // Ceremony options are pre-fetched so the button click reaches // navigator.credentials.* synchronously — Safari drops the user activation // across an intervening network await and rejects with NotAllowedError. let loginBegin = null; function prefetchLogin() { beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; }); } let registerBegin = null; function prefetchRegister() { if (registerBegin) return; beginRegister().then((v) => { registerBegin = v; }).catch(() => { registerBegin = null; }); } // ---- blob save ---------------------------------------------------------------- // saveVault seals the current vault and PUTs it, retrying once on a version // race (re-fetch, re-apply our object over the merge base, re-seal). The list // is tiny, so last-writer-wins per entry is fine at this stage. async function saveVault() { for (let attempt = 0; attempt < 2; attempt++) { const sealed = await sealBlob(vault.obj, dekKey, vault.keys); const v = await putBlob(state.token, sealed, vault.version); if (v !== null) { vault.version = v; return; } const cur = await fetchBlob(state.token); if (!cur) throw new Error("backup disappeared"); const opened = await openBlob(cur.bytes, dekKey); const merged = new Map(opened.obj.servers.map((s) => [s.url, s])); for (const s of vault.obj.servers) merged.set(s.url, s); // Tombstones union newest-per-url, except where OUR list still holds the // entry — this page's list is the user's current intent, so a removal that // raced in from elsewhere loses to it (and an entry we just deleted here // keeps its tombstone and wins over the other writer's copy). const removed = mergeRemoved(opened.obj.removed, vault.obj.removed) .filter((t) => !vault.obj.servers.some((s) => s.url === t.url)); // Method labels merge the same way: the other writer's base, ours // overlaid (last-writer-wins per key; a raced deletion may resurrect a // label — they're a nicety, that's fine at this stage). const methods = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) }; const obj = { ...opened.obj, servers: [...merged.values()].filter((s) => !removed.some((t) => t.url === s.url)), }; if (removed.length) obj.removed = removed; else delete obj.removed; if (Object.keys(methods).length) obj.methods = methods; else delete obj.methods; vault = { obj, keys: opened.keys, version: cur.version }; } throw new Error("could not save after retry"); } // ---- sign in ------------------------------------------------------------------ // All the doors disable together while any of them is mid-flight — a second // WebAuthn request would only cancel the sheet that's already up, and a second // door mid-code-round-trip only confuses. The passkey controls additionally // stay off in a browser with no WebAuthn (the Apple/email doors still work). function setSignedOutBusy(busy) { for (const id of ["apple-signin", "google-signin", "microsoft-signin", "github-signin", "email-signin", "email-send", "email-verify", "other-ways", "otherways-apple", "otherways-google", "otherways-microsoft", "otherways-github", "otherways-email"]) $(id).disabled = busy; for (const id of ["signin", "create-passkey", "unlock-passkey", "otherways-passkey"]) { $(id).disabled = busy || !window.PublicKeyCredential; } } // markSignedIn records which method last signed in on this browser — this is // deliberately the #1112 last-used marker ("passkey" | "email" | "apple"). // localStorage on purpose: it outlives the session-only tab state, and // sign-out leaves it — a returning person is still returning. Its absence is // also what lets the newcomers-only demo card show, so marking retires the // card on the spot. function markSignedIn(method) { try { localStorage.setItem("home_last_signin", method); } catch { /* no storage — the card just stays */ } $("demo-offer").classList.add("hidden"); } // everSignedIn gates the demo card: an absent marker (or no storage at all, // where nothing could ever have been recorded) reads as a newcomer. function everSignedIn() { try { return !!localStorage.getItem("home_last_signin"); } catch { return false; } } // ---- the card algorithm (#1112) ------------------------------------------------ // Which doors are LIVE on this build. Google and Microsoft ship lit; // GitHub lights per-instance below (its flow needs a server-held client // secret, so the page's client id is the signal). A future door slots in // the same way: a flag here + its button in the markup — no restructuring. const DOORS = { apple: true, email: true, passkey: true, google: true, // Android/ChromeOS's platform door (client id below) microsoft: true, // Windows's platform door (client id below) github: false, // set below — lit by the page's per-instance client id }; const GITHUB_CLIENT_ID = (document.querySelector('meta[name="github-client-id"]') || {}).content || ""; DOORS.github = !!GITHUB_CLIENT_ID && GITHUB_CLIENT_ID !== "__GITHUB_CLIENT_ID__"; // platformDefault picks the door for a browser with no last-used marker: the // OS vendor's account, since that's the one a person most likely holds. // userAgentData.platform where it exists (Chromium); navigator.platform + UA // as the fallback (Safari/Firefox — iPadOS reports "MacIntel", which still // reads apple). A platform whose door is still dark falls through to // email-first until it ships. function platformDefault() { const hints = ((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "") + " " + navigator.userAgent; const door = /Mac|iPhone|iPad|iPod|iOS/i.test(hints) ? "apple" : /Android|CrOS|Chrome OS/i.test(hints) ? "google" : /Windows|Win32|Win64/i.test(hints) ? "microsoft" : // never bare /Win/ — "Darwin" contains it "email"; return DOORS[door] ? door : "email"; } // primaryDoor: last-used wins, else the platform default. The marker only // counts when it names a LIVE door — a value from a newer build (say // "google" before this build lights it) must not compose a door that isn't // here. function primaryDoor() { let last = ""; try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* no storage — platform default */ } return DOORS[last] ? last : platformDefault(); } // composeCard arranges the main card: exactly ONE door — last-used when // known, else the platform default — then the quiet "Other ways to sign in" // link. Never a second door on the card (Paul 2026-07-19, revising #1112's // email-always-beneath rule): every demoted door, email included, lives in // the all-ways step. Runs at boot and again on sign-out (the marker // survives it and may have moved). function composeCard() { const primary = primaryDoor(); const apple = $("apple-signin"), email = $("email-signin"), passkey = $("signin"); const google = $("google-signin"), microsoft = $("microsoft-signin"); const github = $("github-signin"); github.classList.toggle("hidden", primary !== "github"); apple.classList.toggle("hidden", primary !== "apple"); google.classList.toggle("hidden", primary !== "google"); microsoft.classList.toggle("hidden", primary !== "microsoft"); // Promotions restyle in place: email sheds .secondary when it leads, and // the passkey door trades its grey link for a proper primary button. // Wholesale className is safe — these two never carry other classes. email.className = primary === "email" ? "" : "secondary hidden"; passkey.className = primary === "passkey" ? "" : "passkey-link hidden"; // DOM order is focus order: the one door, then the link. append MOVES the // existing (already-wired) nodes; hidden doors stay parked, unfocusable. $("main-doors").append({ apple, email, passkey, google, microsoft, github }[primary], $("other-ways")); } async function signIn() { setSignedOutBusy(true); setStatus("Confirming it’s you…", "busy"); const { token, credId, prf, proof } = await runLogin(loginBegin); state.token = token; state.credId = credId; noteProof(proof); // fills the confirm-step pocket (Sign-in options) markSignedIn("passkey"); persistSession(token, "passkey"); const got = await fetchBlob(token); if (!got) { // Zero spaces is a SUCCESS state, not a dead end: land in the normal // signed-in view, empty, with the create affordance. A PRF result lets // this passkey seal future saves (same shape as createPasskey — and like // there, nothing is written until the first space enrols). if (prf) { const kek = await kekFromPRF(prf); dekKey = await generateDEK(); state.dek = await exportDEK(dekKey); vault = { obj: { v: 1, servers: [] }, keys: { [credId]: await wrapDEK(dekKey, kek) }, version: "0" }; } else { dekKey = null; vault = { obj: { v: 1, servers: [] }, keys: {}, version: "0" }; } setStatus(""); showVault(); return; } if (!prf) { setStatus("Signed in, but this browser didn't return a PRF result, so your spaces can't be unlocked here.", "error"); setSignedOutBusy(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"); setSignedOutBusy(false); return; } dekKey = await unwrapDEK(wrapped, kek); state.dek = await exportDEK(dekKey); const opened = await openBlob(got.bytes, dekKey); vault = { obj: opened.obj, keys: opened.keys, version: got.version }; setStatus(""); // "Signed in" is obvious — the list appearing says it showVault(); } // ---- create a sign-in (the up-front "Create your sign-in" action) -------------- // createPasskey registers a brand-new passkey and starts an empty vault under // it, then lands in the (empty) spaces list like any sign-in. Reached from the // up-front create button — quieter than Sign in, but never hidden behind a // failed ceremony (see the header note). async function createPasskey() { setSignedOutBusy(true); setStatus("Creating your sign-in…", "busy"); let reg; try { reg = await runRegister(registerBegin); } catch (e) { registerBegin = null; prefetchRegister(); // options were spent — refresh for a retry if (e.message === "no-prf") { setStatus("This device can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error"); setSignedOutBusy(false); return; } throw e; } state.token = reg.token; state.credId = reg.credId; noteProof(reg.proof); // register/finish mints one too (finishCeremony) markSignedIn("passkey"); // register/finish is a sign-in too (#1112) persistSession(reg.token, "passkey"); const kek = await kekFromPRF(reg.prf); dekKey = await generateDEK(); state.dek = await exportDEK(dekKey); // Deliberately NOT persisted: no blob is written until the first space is // enrolled (enroll.js starts one under this passkey). Blob-existence is the // server's creation-eligibility gate — "has at least one space" — and an // empty vault saved here would quietly turn every fresh passkey into an // eligible account (the + stays hidden, but the API wouldn't 403). vault = { obj: { v: 1, servers: [] }, keys: { [reg.credId]: await wrapDEK(dekKey, kek) }, version: "0", }; setSignedOutBusy(false); setStatus("Your sign-in is ready — next time it's one tap.", "ok"); showVault(); } // ---- the identity anchors: Apple + email --------------------------------------- // startAppleSignIn hands the whole page to Apple's authorize endpoint — a // full-page navigate, no Apple JS SDK (the CSP pins scripts to self). The // state is minted here, "web."-prefixed so the /siwa/return trampoline // bounces back to this page instead of the native apps' custom scheme, and // parked in sessionStorage for the return check. // The Apple round trip's waiting face: the ambient smoke (already running // behind the page) comes to full and a ring holds the lockup's spot — no // brand yet; this is the wait, not the arrival. A pure opacity crossfade // over the boot cover's classes: nothing heavy starts at tap time. The same // face goes up pre-paint on the return leg (the #siwa= gate in index.html). function engageSiwaCover() { document.documentElement.classList.add("booting", "siwa-wait"); } function releaseSiwaCover() { document.documentElement.classList.remove("booting", "siwa-wait"); } // The Apple tap's staged wait: stage-clear → (timer) the waiting face. A // cancelled sheet never navigates, so any later input — or a bfcache // return — releases the whole arrangement, cover included. let applePendingTimer = 0; let applePendingBtn = null; // whichever Apple door was pressed (main card or all-ways) function releaseApplePending() { clearTimeout(applePendingTimer); releaseSiwaCover(); document.querySelector("main.card").classList.remove("apple-pending"); if (applePendingBtn) { applePendingBtn.disabled = false; applePendingBtn.classList.remove("loading"); applePendingBtn = null; } window.removeEventListener("pointerdown", releaseApplePending, true); window.removeEventListener("keydown", releaseApplePending, true); // A cancelled trip must not leave the methods step's round-trip markers // behind — a stale one would misroute a LATER Apple sign-in into link mode. try { sessionStorage.removeItem("home_siwa_link"); sessionStorage.removeItem("home_methods_return"); sessionStorage.removeItem("home_methods_intent"); } catch { /* nothing stored */ } } function startAppleSignIn(e, opts = {}) { // Tap: the button spins and the REST of the form fades away — the wait // for Apple's sheet is just the button at work. By 1.6s the sheet is up, // so the smoke cover takes over then; post-fingerprint there is nothing // left to flash. pagehide backstops fast confirms; pointer/key input or a // bfcache return releases a cancelled arrangement. The main card's door // and the all-ways step's are the same handler — the stage-clear CSS keys // off .loading, not an id, so it clears whichever surface is up. const appleBtn = e.currentTarget; applePendingBtn = appleBtn; appleBtn.disabled = true; appleBtn.classList.add("loading"); document.querySelector("main.card").classList.add("apple-pending"); applePendingTimer = setTimeout(engageSiwaCover, 1600); window.addEventListener("pointerdown", releaseApplePending, true); window.addEventListener("keydown", releaseApplePending, true); const rand = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join(""); const st = "web." + rand; // The methods step's round-trip markers ride sessionStorage beside the // state: linkProof turns the #siwa= return into "add Apple to MY account" // (link:true + the stashed proof), methodsReturn routes a confirm-with- // Apple sign-in straight back to the step, and intent is the serialized // in-progress change (intentToken) so the return RESUMES it instead of // landing on the bare list. Set fresh or CLEARED per trip, so a stale // marker from an abandoned attempt can never misroute later. try { if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof)); else sessionStorage.removeItem("home_siwa_link"); if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1"); else sessionStorage.removeItem("home_methods_return"); if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent); else sessionStorage.removeItem("home_methods_intent"); } catch { /* no storage — the return leg lands as a plain sign-in */ } sessionStorage.setItem("home_siwa_state", st); location.href = "https://appleid.apple.com/auth/authorize" + "?client_id=com.elevenmessenger.signin" + "&redirect_uri=" + encodeURIComponent(location.origin + "/siwa/return") + "&response_type=code%20id_token" + "&response_mode=form_post" + "&state=" + encodeURIComponent(st); } // finishSiwa completes the round trip: the id_token from the return fragment // goes to the one verifier (/api/siwa/verify), which answers exactly like a // passkey ceremony — then the anchor-session landing sorts the outcomes. The // pre-paint waiting face (smoke + ring, html.siwa-wait) is up the whole time; // verify success earns the arrival: ring gives way to the Eleven lockup, // a beat to land, then the cover lifts as the spaces screen slides up. async function finishSiwa(idToken, backToMethods = false, intentStr = "") { setStatus("Confirming it’s you…", "busy"); try { const res = await apiJSON("/api/siwa/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity_token: idToken }), }); noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) markSignedIn("apple"); persistSession(res.token, "apple"); await enterAnchorSession(res); // renders the landing beneath the cover } catch { if (backToMethods && state.token) { // A failed confirm-with-Apple: the tab's session is untouched — land // back on the list with the refusal (the intent was already consumed). await openMethods(); methodsStatus("That Apple sign-in couldn’t be confirmed — try again.", "error"); finishHomeLoader({ immediate: true }); return; } // No brand moment for a sign-in that didn't happen: lift straight to // the signed-out card and say what went wrong. setStatus("That Apple sign-in couldn’t be confirmed — try again.", "error"); finishHomeLoader({ immediate: true }); return; } if (backToMethods) { // A confirm-with-Apple round trip (the methods step sent us): no arrival // pageantry — straight back to Sign-in options, proof in the pocket. A // persisted intent resumes the interrupted change — the add step opens // (or the removal re-arms) instead of landing on the bare list. await openMethods(); const intent = parseIntentToken(intentStr); if (intent && freshProof()) runIntent(intent, freshProof()); finishHomeLoader({ immediate: true }); return; } anchorArrival(); } // anchorArrival is phase two for a covered anchor sign-in (Apple's return leg, // the email magic link): the lockup fades in over the ring's spot (~400ms), // dwells (~450ms), then the cover lifts while the spaces screen slides up // into place beneath it. Reduced motion: no dwell, no slide — plain fades // (brand-in and arrive are inert under the reduce query). function anchorArrival() { const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; document.documentElement.classList.remove("siwa-wait"); // ring → lockup $("app-loading").classList.add("brand-in"); setTimeout(() => { // .arrive stays on while the vault is visible — swapping the computed // animation-name back to .screen's live would RESTART the entry fade // (the after-flash); it comes off in hideVault, under display:none. $("vault").classList.add("arrive"); finishHomeLoader({ immediate: true }); }, reduced ? 0 : 850); // 400ms brand fade + 450ms dwell } // finishSiwaLink completes an ADD-APPLE round trip (the #siwa= return with a // link marker): the ceremony proof stashed before leaving for Apple rode // sessionStorage — same tab — and must still be inside its 5-minute window on // return. The link verify never signs anyone in; this tab's own session // (which survived the navigation) is resumed instead, and the outcome lands // inline on the Sign-in options step. async function finishSiwaLink(idToken, markRaw) { let mark = null; try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ } let outcome, conflict = false; if (!mark || !mark.proof || !(mark.expires > Date.now())) { // The proof's 5-minute window lapsed during the trip — re-prompt. dropProof(); // the pocket mirrors the same lapsed proof outcome = { msg: "That took a little too long — confirm it's you again, then tap Add Apple.", kind: "error" }; } else { try { const res = await fetch("/api/siwa/verify", { method: "POST", headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof }, body: JSON.stringify({ identity_token: idToken, link: true }), }); if (res.ok) { // The proof just verified — good for the window's remainder, so a // chained second add skips the confirm step. noteProof(mark.proof, mark.expires); outcome = { msg: "Apple is now a way into your account.", kind: "ok" }; } else { // The server's refusals verbatim: a lapsed proof (401 — the line // asks to confirm again), or the 409 "that Apple ID already signs in // somewhere else" — the COMBINE doorway (#1098): the offer keys off // the status, never off message text. const msg = ((await res.text().catch(() => "")) || "").trim(); conflict = res.status === 409; // A 409 still verified the proof (only the identity clashed); any // other refusal is almost always the lapsed proof — re-confirm next. if (conflict) noteProof(mark.proof, mark.expires); else dropProof(); outcome = { msg: msg || "Couldn't add Apple — try again.", kind: "error" }; } } catch { outcome = { msg: "Couldn't add Apple — try again.", kind: "error" }; } } if (await resumeAfterLinkReturn()) { await openMethods(); methodsStatus(outcome.msg, outcome.kind); if (conflict) { combine = { kind: "apple" }; showCombineOffer("methods-status"); } } else { // No session survived the round trip (storage cleared mid-flight) — the // doors, with the outcome where the visitor will read it. setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error"); } finishHomeLoader({ immediate: true }); } // ---- the OIDC web doors (Google today; Microsoft slots in) ---------------------- // The Apple journey, generalized per oidc.go's provider table: the page // mints the state AND a nonce (Google refuses a bare id_token request; the // verifier checks it round-trips), departs for the provider's authorize // endpoint, and lands back through the provider-blind /oidc/return // trampoline as #oidc=&state=web.. Which door was opened rides // sessionStorage (home_oidc_state), never the fragment. The pending/cover // choreography and the methods round-trip markers are the Apple machinery, // shared verbatim. Like Apple's client id, a provider's is a public // identifier and lives here in code. const GOOGLE_CLIENT_ID = "981276018948-b4cr3ctqi6cr9e8rs2fmqouip1hmd9iq.apps.googleusercontent.com"; const MICROSOFT_CLIENT_ID = "25874cf3-213c-4124-9641-65de8a513787"; const OIDC_WEB = { github: { display: "GitHub", verify: "/api/oidc/github/verify", // Code flow, not OIDC: no nonce; the server exchanges the code with // the secret it holds (github.go) and answers the same contract. payload: (cred) => ({ code: cred }), authorize: (st) => "https://github.com/login/oauth/authorize" + "?client_id=" + encodeURIComponent(GITHUB_CLIENT_ID) + "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") + "&state=" + encodeURIComponent(st), }, microsoft: { display: "Microsoft", verify: "/api/oidc/microsoft/verify", // The "common" endpoint admits any tenant plus personal accounts — // oidc.go's checkIssuer handles the per-tenant iss. authorize: (st, nonce, sel) => "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + "?client_id=" + encodeURIComponent(MICROSOFT_CLIENT_ID) + "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") + "&response_type=id_token" + "&response_mode=form_post" + "&scope=openid" + "&nonce=" + encodeURIComponent(nonce) + "&state=" + encodeURIComponent(st) + (sel ? "&prompt=select_account" : ""), }, google: { display: "Google", verify: "/api/oidc/google/verify", authorize: (st, nonce, sel) => "https://accounts.google.com/o/oauth2/v2/auth" + "?client_id=" + encodeURIComponent(GOOGLE_CLIENT_ID) + "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") + "&response_type=id_token" + "&response_mode=form_post" + "&scope=openid" + "&nonce=" + encodeURIComponent(nonce) + "&state=" + encodeURIComponent(st) + (sel ? "&prompt=select_account" : ""), }, }; function startOidcSignIn(provider, e, opts = {}) { // The same staged wait as the Apple tap (see startAppleSignIn): button // spins, stage clears, the smoke cover takes over by the time the // provider's page is up; any input releases a cancelled arrangement. const btn = e.currentTarget; applePendingBtn = btn; btn.disabled = true; btn.classList.add("loading"); document.querySelector("main.card").classList.add("apple-pending"); applePendingTimer = setTimeout(engageSiwaCover, 1600); window.addEventListener("pointerdown", releaseApplePending, true); window.addEventListener("keydown", releaseApplePending, true); const hex = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join(""); const st = "web." + hex(); const nonce = hex(); try { if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof)); else sessionStorage.removeItem("home_siwa_link"); if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1"); else sessionStorage.removeItem("home_methods_return"); if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent); else sessionStorage.removeItem("home_methods_intent"); } catch { /* no storage — the return leg lands as a plain sign-in */ } sessionStorage.setItem("home_oidc_state", JSON.stringify({ state: st, provider, nonce })); location.href = OIDC_WEB[provider].authorize(st, nonce, !!opts.select); } // finishOidc — finishSiwa's twin against /api/oidc/{provider}/verify: the // same one-verifier answer (token + proof), the same anchor-session landing // under the waiting face. function oidcPayload(p, cred, rec) { return p.payload ? p.payload(cred) : { identity_token: cred, nonce: rec.nonce }; } async function finishOidc(rec, cred, backToMethods = false, intentStr = "") { const p = OIDC_WEB[rec.provider]; setStatus("Confirming it’s you…", "busy"); try { const res = await apiJSON(p.verify, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(oidcPayload(p, cred, rec)), }); noteProof(res.proof); markSignedIn(rec.provider); persistSession(res.token, rec.provider); await enterAnchorSession(res); } catch { if (backToMethods && state.token) { await openMethods(); methodsStatus("That " + p.display + " sign-in couldn’t be confirmed — try again.", "error"); finishHomeLoader({ immediate: true }); return; } setStatus("That " + p.display + " sign-in couldn’t be confirmed — try again.", "error"); finishHomeLoader({ immediate: true }); return; } if (backToMethods) { await openMethods(); const intent = parseIntentToken(intentStr); if (intent && freshProof()) runIntent(intent, freshProof()); finishHomeLoader({ immediate: true }); return; } // The landing rendered beneath the cover — this is what LIFTS it (the // ring gives way to the lockup, then the spaces screen). Its omission // stranded the first live Google sign-in on the waiting face. anchorArrival(); } // finishOidcLink — finishSiwaLink's twin: the ADD round trip (link:true + // the stashed proof). The 409 grows the same combine offer as Apple's leg — // the prove step sends the person back through the clashing provider's own // door (combine-oidc-go → the merge- intent). async function finishOidcLink(rec, cred, markRaw) { const p = OIDC_WEB[rec.provider]; let mark = null; try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ } let outcome, conflict = false; if (!mark || !mark.proof || !(mark.expires > Date.now())) { dropProof(); // the pocket mirrors the same lapsed proof outcome = { msg: "That took a little too long — confirm it's you again, then tap Add " + p.display + ".", kind: "error" }; } else { try { const res = await fetch(p.verify, { method: "POST", headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof }, body: JSON.stringify({ ...oidcPayload(p, cred, rec), link: true }), }); if (res.ok) { // The proof just verified — good for the window's remainder, so a // chained second add skips the confirm step. noteProof(mark.proof, mark.expires); outcome = { msg: p.display + " is now a way into your account.", kind: "ok" }; } else { const msg = ((await res.text().catch(() => "")) || "").trim(); conflict = res.status === 409; // A 409 still verified the proof (only the identity clashed); any // other refusal is almost always the lapsed proof — re-confirm next. if (conflict) noteProof(mark.proof, mark.expires); else dropProof(); outcome = { msg: msg || "Couldn't add " + p.display + " — try again.", kind: "error" }; } } catch { outcome = { msg: "Couldn't add " + p.display + " — try again.", kind: "error" }; } } if (await resumeAfterLinkReturn()) { await openMethods(); methodsStatus(outcome.msg, outcome.kind); if (conflict) { combine = { kind: rec.provider }; showCombineOffer("methods-status"); } } else { setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error"); } finishHomeLoader({ immediate: true }); } // finishSiwaMergeProve is the merge-apple intent's return leg: the combine // step sent the person to Apple to prove the OTHER account is theirs. The // id_token verifies in SIGN-IN mode, but the answer is deliberately NOT // adopted — the absorbed account's token+proof are held in memory for the // merge only, and this tab's own (survivor) session resumes as-is. async function finishSiwaMergeProve(idToken) { let creds = null; try { const res = await apiJSON("/api/siwa/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity_token: idToken }), }); creds = { token: res.token, proof: res.proof, expires: Date.now() + PROOF_TTL_MS }; } catch { /* falls through to the refusal below */ } if (!(await resumeAfterLinkReturn())) { setStatus("The combine couldn't resume — sign in and try again.", "error"); finishHomeLoader({ immediate: true }); return; } await openMethods(); if (!creds) { methodsStatus("That Apple sign-in couldn’t be confirmed — try combining again.", "error"); finishHomeLoader({ immediate: true }); return; } combine = { kind: "apple", absorbedToken: creds.token, absorbedProof: creds.proof, absorbedExpires: creds.expires }; openCombineStep(); // proven — lands straight on the spaces stage finishHomeLoader({ immediate: true }); } // finishOidcMergeProve — finishSiwaMergeProve's provider twin: the combine // step sent the person to Google/Microsoft/GitHub to prove the OTHER // account; the answer is held for the merge, never adopted. async function finishOidcMergeProve(rec, cred) { const p = OIDC_WEB[rec.provider]; let creds = null; try { const res = await apiJSON(p.verify, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(oidcPayload(p, cred, rec)), }); creds = { token: res.token, proof: res.proof, expires: Date.now() + PROOF_TTL_MS }; } catch { /* falls through to the refusal below */ } if (!(await resumeAfterLinkReturn())) { setStatus("The combine couldn't resume — sign in and try again.", "error"); finishHomeLoader({ immediate: true }); return; } await openMethods(); if (!creds) { methodsStatus("That " + p.display + " sign-in couldn't be confirmed — try combining again.", "error"); finishHomeLoader({ immediate: true }); return; } combine = { kind: rec.provider, absorbedToken: creds.token, absorbedProof: creds.proof, absorbedExpires: creds.expires }; openCombineStep(); // proven — lands straight on the spaces stage finishHomeLoader({ immediate: true }); } // resumeAfterLinkReturn rebuilds the signed-in view after an add-Apple round // trip: the tab's own session survived the navigation (sessionStorage), or // the stay-signed-in token did (localStorage). True once a signed-in screen // is up again; false means every session evaporated mid-trip. async function resumeAfterLinkReturn() { try { if (state.token && state.dek) { const got = await fetchBlob(state.token); if (got) { dekKey = await importDEK(state.dek); const opened = await openBlob(got.bytes, dekKey); vault = { obj: opened.obj, keys: opened.keys, version: got.version }; showVault(); return true; } } const saved = state.token ? { token: state.token } : persistedSession(); if (saved) { await enterAnchorSession({ token: saved.token, created: false }); return true; } } catch { /* fall through to the doors */ } return false; } // The email door (email.go): type your address, get a 6-digit code, type it — // find-or-create like Apple. The address goes only into the two request // bodies; nothing here stores it. It's a card step of its own — the doors // swap out for it, same screen-swap pattern as the signed-in screens. It can // be entered from the main card or the all-ways step; Back returns to // whichever surface opened it. let emailDoorFrom = "signed-out"; function openEmailDoor() { emailDoorFrom = $("otherways").classList.contains("hidden") ? "signed-out" : "otherways"; $("signed-out").classList.add("hidden"); $("otherways").classList.add("hidden"); $("reassure").classList.add("hidden"); $("email-door").classList.remove("hidden"); setStatus(""); $("email-addr").focus(); } // resetEmailDoor folds the door back to its address step (shared with // signOut, so the next visitor never sees a half-finished code round trip). function resetEmailDoor() { $("email-door").classList.add("hidden"); $("email-code-form").classList.add("hidden"); $("email-form").classList.remove("hidden"); $("email-note").classList.remove("hidden"); $("email-addr").value = ""; $("email-code").value = ""; } function closeEmailDoor() { resetEmailDoor(); setStatus(""); if (emailDoorFrom === "otherways") { // Back to the all-ways step; #signed-out stays hidden, so the reassure/ // cookie/greeter visibility rules stay as the step left them. $("otherways").classList.remove("hidden"); return; } $("signed-out").classList.remove("hidden"); $("reassure").classList.remove("hidden"); } // ---- the all-ways step (#1112) -------------------------------------------------- // openOtherWays swaps the card to the every-door step — the same screen-swap // pattern as the email door (hiding #signed-out is what keeps the greeter/ // cookie/reassure rules honest). Focus lands on the first door; Back hands // it back to the link. function openOtherWays() { $("signed-out").classList.add("hidden"); $("reassure").classList.add("hidden"); // The #newhere explainer follows the visible sign-in surface (revealNewhere). if (!$("newhere").classList.contains("hidden")) $("otherways").append($("newhere")); $("otherways").classList.remove("hidden"); setStatus(""); $("otherways-apple").focus(); } function closeOtherWays() { resetOtherWays(); setStatus(""); $("signed-out").classList.remove("hidden"); $("reassure").classList.remove("hidden"); $("other-ways").focus(); } // resetOtherWays parks the all-ways step and re-homes the #newhere explainer // to its markup home — shared with the arrival paths (showVault and friends), // so signing in from the step never strands either piece. function resetOtherWays() { $("otherways").classList.add("hidden"); $("signed-out").append($("newhere")); } // revealNewhere shows the after-failure explainer on whichever sign-in // surface is up — the passkey door sits on the main card only when promoted, // so most ceremonies (and their failures) happen inside the all-ways step. function revealNewhere() { ($("otherways").classList.contains("hidden") ? $("signed-out") : $("otherways")) .append($("newhere")); $("newhere").classList.remove("hidden"); } async function sendEmailCode(e) { e.preventDefault(); const email = $("email-addr").value.trim(); setSignedOutBusy(true); setStatus("Sending your code…", "busy"); // The button spins for at least a beat even when the server is instant — // a mail send that appears to take zero time reads as a glitch, and the // sudden step-swap was jarring (Paul). The floor, not a fake: the request // runs concurrently. const sendBtn = $("email-send"); sendBtn.classList.add("loading"); const spinFloor = new Promise((r) => setTimeout(r, 900)); try { await Promise.all([apiJSON("/api/email/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }), spinFloor]); // The server answers the same 200 known-or-not (no enumeration), so this // line is honest for everyone. // The code step takes the address step's PLACE — not a row below it. setStatus("Check your email for a 6-digit code.", ""); $("email-form").classList.add("hidden"); $("email-note").classList.add("hidden"); $("email-code-form").classList.remove("hidden"); setSignedOutBusy(false); sendBtn.classList.remove("loading"); $("email-code").focus(); } catch (err) { setStatus(err.message || "Couldn’t send the code — try again.", "error"); setSignedOutBusy(false); sendBtn.classList.remove("loading"); } } async function verifyEmailCode(e) { e.preventDefault(); const email = $("email-addr").value.trim(); const code = $("email-code").value.trim(); setSignedOutBusy(true); setStatus("Checking your code…", "busy"); // Same trade as Send code, a touch longer (Paul: "it's too fast!"): the // button spins for a floor the verify races, so the signed-in landing // never swaps in before the moment has registered. const verifyBtn = $("email-verify"); verifyBtn.classList.add("loading"); const spinFloor = new Promise((r) => setTimeout(r, 1200)); try { const [res] = await Promise.all([apiJSON("/api/email/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, code }), }), spinFloor]); noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) markSignedIn("email"); persistSession(res.token, "email"); await enterAnchorSession(res); setSignedOutBusy(false); verifyBtn.classList.remove("loading"); } catch (err) { // The server's refusals are member-facing ("that code didn't work"). setStatus(err.message || "That code didn’t work — try again.", "error"); setSignedOutBusy(false); verifyBtn.classList.remove("loading"); } } // finishEmailLink completes the mail's magic link (the #email= boot fragment): // the address+code from the fragment — the very values the mail asks you to // type — go to the ordinary /api/email/verify, so the link shares the code's // single-use/expiry/attempt machinery and adds no auth surface of its own. // The pre-paint waiting face is up the whole time; success lands exactly like // the typed-code path, then earns the same arrival as Apple's return. async function finishEmailLink(email, code) { setStatus("Confirming it’s you…", "busy"); try { const res = await apiJSON("/api/email/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, code }), }); noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) markSignedIn("email"); persistSession(res.token, "email"); await enterAnchorSession(res); // renders the landing beneath the cover } catch { // An expired or already-redeemed code: no brand moment — lift straight to // the doors and say what to do next. setStatus("That sign-in link has expired — request a fresh code.", "error"); finishHomeLoader({ immediate: true }); return; } anchorArrival(); } // enterAnchorSession lands an email/Apple sign-in. The token is held exactly // like the passkey path holds its own (sessionStorage, this tab only); the // ceremony proof is not kept, matching the passkey path, which only ever uses // one transiently. An anchor authenticates but can never decrypt (no PRF), so: // fresh account or no blob yet → the normal signed-in view, empty; an // existing sealed blob → the quiet unlock-with-your-passkey prompt. async function enterAnchorSession(res) { state.token = res.token; dekKey = null; const got = res.created ? null : await fetchBlob(res.token); if (got) { vault = null; setStatus(""); showSealedVault(); return; } vault = { obj: { v: 1, servers: [] }, keys: {}, version: "0" }; setStatus(""); showVault(); } // showSealedVault is the signed-in view for an account whose list exists but // can't open here: the token works, the blob is real, and the DEK that opens // it lives behind a passkey's PRF. The prompt runs the ordinary sign-in // ceremony, which decrypts and takes over the view. function showSealedVault() { $("signed-out").classList.add("hidden"); $("reassure").classList.add("hidden"); resetEmailDoor(); // arriving from the email door hides it resetOtherWays(); // …and from the all-ways step likewise $("vault").classList.remove("hidden"); $("open-create").classList.add("hidden"); $("space-search").classList.add("hidden"); $("spaces").textContent = ""; $("vault-sealed").classList.remove("hidden"); } // ---- sign out ----------------------------------------------------------------- // signOut forgets this browser's session — the tab's token and DEK, and the // persisted stay-signed-in token — and returns to the signed-out landing. It // doesn't revoke the passkey or touch the backup: signing back in is one tap. // (Revoking a device is a separate, ceremony-gated action.) The #1112 // last-used marker deliberately survives: a returning person is still // returning. function signOut() { sessionStorage.removeItem("home_token"); sessionStorage.removeItem("home_dek"); sessionStorage.removeItem("home_cred"); clearPersistedSession(); vault = null; dekKey = null; provisioned = null; // the ledger read is per-sign-in hideVault(); $("create").classList.add("hidden"); $("create-plan").classList.add("hidden"); $("upgrade").classList.add("hidden"); $("claim").classList.add("hidden"); $("archive").classList.add("hidden"); for (const s of METHOD_SCREENS) $(s).classList.add("hidden"); resetMethodsSteps(); pendingIntent = null; combine = null; anchors = null; armedRemove = ""; dropProof(); // proofs are per-person, never per-browser archiving = null; clearUndo(); $("spaces").textContent = ""; $("vault-sealed").classList.add("hidden"); $("signed-out").classList.remove("hidden"); $("newhere").classList.add("hidden"); // fresh signed-out state, no stale explainer resetEmailDoor(); // back to the doors, address step ready for next time resetOtherWays(); composeCard(); // the last-used marker survives sign-out and may have moved the primary $("reassure").classList.remove("hidden"); setSignedOutBusy(false); setStatus("Signed out.", ""); prefetchLogin(); // ready a fresh ceremony for the next sign-in } // ---- the account menu (issue #1098's single control) --------------------------- // One kebab in the vault header collects everything about the account itself: // "Sign-in options" (the methods step family below) and Sign out; // account-merge joins it later (#1098's next slice). // Menu semantics by hand: focus lands on the first item on open and returns // to the kebab on close; Escape and click-outside both close. function accountMenuOpen() { return !$("account-menu").classList.contains("hidden"); } // A pointerdown on the kebab marks the NEXT open as pointer-born: focus // still moves into the menu (menu semantics), but the ring stays keyboard- // only — Chrome's focus-visible heuristic treats script focus as keyboard // and over-eagerly ringed mouse opens (Paul's catch). let kebabPointerOpen = false; function openAccountMenu() { $("account-menu").classList.toggle("pointer-open", kebabPointerOpen); kebabPointerOpen = false; $("account-menu").classList.remove("hidden"); $("account-menu-btn").setAttribute("aria-expanded", "true"); $("account-menu").querySelector('[role="menuitem"]').focus(); addEventListener("pointerdown", accountMenuOutside, true); addEventListener("keydown", accountMenuKey, true); } // close(refocus): refocus only for keyboard paths (Escape, the kebab toggle) — // a click elsewhere shouldn't yank focus back to the header. function closeAccountMenu(refocus) { if (!accountMenuOpen()) return; $("account-menu").classList.add("hidden"); $("account-menu-btn").setAttribute("aria-expanded", "false"); removeEventListener("pointerdown", accountMenuOutside, true); removeEventListener("keydown", accountMenuKey, true); if (refocus) $("account-menu-btn").focus(); } function accountMenuOutside(e) { if (!e.target.closest || !e.target.closest(".kebab-wrap")) closeAccountMenu(false); } function accountMenuKey(e) { if (e.key === "Escape") { e.preventDefault(); closeAccountMenu(true); } } // ---- sign-in options (issue #1098) ---------------------------------------------- // The ceremony-proof pocket. Removing or adding a sign-in method needs a // FRESH proof (the server holds them to a 5-minute window — stateless.go's // ceremonyWindow); every ceremony that mints one drops it here, and // freshProof answers only while a client-side mirror of that window still // has margin, so a call never leaves with a proof that will be refused on // arrival. The pocket mirrors into sessionStorage — same tab, like the // round-trip markers — because adding Apple or an OIDC provider IS a // full-page navigation: without the mirror, every return leg came back to // an empty pocket and the next change re-demanded a ceremony inside the // same 5-minute window. Sign-out drops it (proofs are per-person). const PROOF_TTL_MS = 5 * 60 * 1000; const PROOF_MARGIN_MS = 10 * 1000; let ceremonyProof = null; // { proof, expires } | null function noteProof(proof, expires) { if (!proof) return; ceremonyProof = { proof, expires: expires || Date.now() + PROOF_TTL_MS }; try { sessionStorage.setItem("home_proof", JSON.stringify(ceremonyProof)); } catch { /* memory still holds it */ } } function dropProof() { ceremonyProof = null; try { sessionStorage.removeItem("home_proof"); } catch { /* nothing stored */ } } function freshProof() { if (!ceremonyProof) { try { ceremonyProof = JSON.parse(sessionStorage.getItem("home_proof") || "null"); } catch { /* mangled — an empty pocket */ } } return ceremonyProof && ceremonyProof.expires - PROOF_MARGIN_MS > Date.now() ? ceremonyProof.proof : null; } // The kind vocabulary, generalized: every sign-in method the product knows, // in listing order. A linked kind's ADD lives in each of its rows' kebab (a // plain "Add another" — the row already names the kind); a kind with ZERO // linked entries gets a big first-add button below the list instead. Future // doors (google/microsoft) slot in here — same shape, gated by their DOORS // flag — with no restructuring. const METHOD_KINDS = [ { kind: "passkey", name: "Passkey", first: "Add a passkey", enabled: () => !!window.PublicKeyCredential, start: (e) => startAddPasskey(e) }, { kind: "siwa", name: "Apple", first: "Add Apple", enabled: () => DOORS.apple, start: (e) => startAddApple(e) }, { kind: "github", name: "GitHub", first: "Add GitHub", enabled: () => DOORS.github, start: (e) => startAddOidc("github", e) }, { kind: "microsoft", name: "Microsoft", first: "Add Microsoft", enabled: () => DOORS.microsoft, start: (e) => startAddOidc("microsoft", e) }, { kind: "google", name: "Google", first: "Add Google", enabled: () => DOORS.google, start: (e) => startAddGoogle(e) }, { kind: "email", name: "Email", first: "Add email", enabled: () => DOORS.email, start: (e) => startAddEmail(e) }, ]; // The step family is SERIAL card screens — one at a time, each with a back: // the list, the confirm beat, add-email, add-passkey. (Add-Apple's "step" is // the Apple journey itself.) showMethodsScreen swaps within the family; // callers manage focus. const METHOD_SCREENS = ["methods", "methods-confirm", "methods-email", "methods-passkey", "methods-combine"]; function showMethodsScreen(id) { closeRowMenu(false); for (const s of METHOD_SCREENS) $(s).classList.toggle("hidden", s !== id); } function methodsStatus(msg, kind) { const el = $("methods-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } function confirmStatus(msg, kind) { const el = $("methods-confirm-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } function emailStepStatus(msg, kind) { const el = $("methods-email-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } function passkeyStepStatus(msg, kind) { const el = $("methods-passkey-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } // anchors is the last-fetched listing (GET /api/anchors) — renderMethods' // input, and what the confirm step consults to pick a ceremony this account // can actually run. armedRemove holds the row currently in its inline // are-you-sure state, so re-renders keep it armed. let anchors = null; let armedRemove = ""; // pendingIntent is the change the confirm step interrupted, AS DATA — the one // continuation system behind every confirm path. In-page ceremonies resume it // from memory (payloads intact); the confirm-with-Apple round trip carries // its intentToken through sessionStorage and the return leg resumes the same // way. Shapes: { kind: "add-email" | "add-passkey" | "add-apple" }, { kind: // "verify-email", email, code } (memory-only payload), { kind: "remove", // anchorKind, id, armed }. let pendingIntent = null; // intentToken / parseIntentToken serialize a pending intent for the Apple // confirmation's full-page round trip (sessionStorage, set-or-cleared per // trip like the other markers). Only the change's IDENTITY rides — never a // payload: a typed address/code stays in memory, so "verify-email" degrades // to reopening the add-email step (the same mailed code can be retyped // there), and a removal's passed are-you-sure is deliberately dropped (a // reloaded page re-arms rather than auto-deleting). Anchor ids are // base64url/hex, so ":" can't ambiguate the remove form. function intentToken(intent) { if (!intent) return ""; if (intent.kind === "remove") return "remove:" + intent.anchorKind + ":" + intent.id; if (intent.kind === "verify-email") return "add-email"; if (intent.kind === "combine-finish") return ""; // the combine context is memory-only return intent.kind; // add-email | add-passkey | add-apple } function parseIntentToken(s) { if (["add-email", "add-passkey", "add-apple", "add-google", "add-microsoft", "add-github"].includes(s)) return { kind: s }; const m = /^remove:([^:]+):(.+)$/.exec(s || ""); return m ? { kind: "remove", anchorKind: m[1], id: m[2] } : null; } // runIntent performs a parked change once a fresh proof is in hand. Callers // guarantee the pocket is fresh; the intent decides its own navigation. function runIntent(intent, proof) { switch (intent.kind) { case "add-email": openMethodsEmailStep(); return; case "add-passkey": openMethodsPasskeyStep(); return; case "add-apple": startAddApple(null); return; // pocket is fresh — straight to Apple in link mode case "add-google": startAddOidc("google", null); return; case "add-microsoft": startAddOidc("microsoft", null); return; case "add-github": startAddOidc("github", null); return; case "verify-email": // In-memory only (never serialized): retry the exact typed code — it // survived, link mode gates on the proof BEFORE redeeming (email.go). backToMethodsList("", "", { reload: false }); finishEmailAdd(intent.email, intent.code, proof); return; case "combine-finish": // In-memory only: the survivor's proof lapsed mid-combine; finish now. combineFinish(intent.movedSpaces || 0); return; case "remove": { const a = (anchors || []).find((x) => x.kind === intent.anchorKind && x.id === intent.id); backToMethodsList("", "", { reload: !a }); // row gone meanwhile → just the fresh list if (!a) return; if (intent.armed) { // The are-you-sure was passed right before an in-page confirm — // complete the removal. doRemoveAnchor(a, proof); } else { // The Apple round trip reloaded the page: the destructive tap stays // explicit — re-arm THAT row's are-you-sure instead of auto-deleting. armedRemove = intent.anchorKind + "/" + intent.id; renderMethods(); focusMethodRow(armedRemove, "button.danger-ink"); } return; } } } async function openMethods() { hideVault(); resetMethodsSteps(); armedRemove = ""; pendingIntent = null; methodsStatus(""); showMethodsScreen("methods"); $("methods-title").focus(); // announce the step; rows load in beneath await loadAnchors(); } function closeMethods() { resetMethodsSteps(); pendingIntent = null; combine = null; // abandoning the flow touches neither account for (const s of METHOD_SCREENS) $(s).classList.add("hidden"); // Back to whichever signed-in view this session actually has — anchor-born // sessions may still be sealed (no vault object to render). if (vault) showVault(); else showSealedVault(); } // backToMethodsList returns from a sub-step (or finishes a flow) to the // list, with the outcome line where the person lands. function backToMethodsList(msg, kind, { reload = true } = {}) { showMethodsScreen("methods"); $("methods-title").focus(); methodsStatus(msg || "", kind); if (reload) loadAnchors(); } // resetMethodsSteps parks every sub-step — shared by open/close/sign-out so // no half-finished round trip greets the next visit. function resetMethodsSteps() { // The confirm step. $("methods-confirm-passkey").classList.add("hidden"); $("methods-confirm-apple").classList.add("hidden"); $("methods-confirm-google").classList.add("hidden"); $("methods-confirm-microsoft").classList.add("hidden"); $("methods-confirm-github").classList.add("hidden"); $("methods-confirm-email").classList.add("hidden"); $("methods-cemail").classList.add("hidden"); $("methods-cemail-form").classList.remove("hidden"); $("methods-cemail-code-form").classList.add("hidden"); $("methods-cemail-addr").value = ""; $("methods-cemail-code").value = ""; confirmStatus(""); // The add-email step. $("methods-email-form").classList.remove("hidden"); $("methods-email-code-form").classList.add("hidden"); $("methods-email-addr").value = ""; $("methods-email-code").value = ""; emailStepStatus(""); // The add-passkey step. passkeyAddBegin = null; passkeyStepStatus(""); // The combine step's stages + the 409 offer (the combine CONTEXT itself // survives — only closing the family or finishing clears it). $("combine-offer").classList.add("hidden"); $("combine-prove").classList.add("hidden"); $("combine-code").classList.add("hidden"); $("combine-apple-go").classList.add("hidden"); $("combine-oidc-go").classList.add("hidden"); $("combine-code-input").value = ""; $("combine-vault").classList.add("hidden"); $("combine-unlock").classList.add("hidden"); $("combine-skip").classList.add("hidden"); $("combine-skip-confirm").classList.add("hidden"); combineStatus(""); } async function loadAnchors() { try { const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } }); anchors = r.anchors || []; renderMethods(); } catch (e) { methodsStatus(e.message || "Couldn't load your sign-in options — try again.", "error"); } } // methodAdded is the row's quiet second line — "added 12 Jul", year only // when it isn't this year's. function methodAdded(ts) { if (!ts) return ""; const d = new Date(ts * 1000); const opts = { day: "numeric", month: "short" }; if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"; return "added " + d.toLocaleDateString(undefined, opts); } // ---- method labels -------------------------------------------------------------- // A method row's human label ("personal", the email address) is E2EE state, // never server metadata: vault.obj.methods = { "/": { label } }, // keyed the way the anchors listing names rows, riding the sealed blob like // space names do. Sealed sessions (no DEK) neither show nor offer labels. let editingLabel = ""; function canLabel() { return !!(vault && dekKey); } function methodLabel(key) { const m = vault && vault.obj && vault.obj.methods; return (m && m[key] && m[key].label) || ""; } async function setMethodLabel(key, label) { if (!canLabel()) return; const m = { ...(vault.obj.methods || {}) }; if (label) m[key] = { label }; else delete m[key]; if (Object.keys(m).length) vault.obj.methods = m; else delete vault.obj.methods; await saveVault(); } // labelNewEmail stores a just-verified address as its anchor's label. The // client can't compute the anchor id (that's the server's peppered blind // index), so diff the listing: the email id that wasn't there before the // add is the one just created. async function labelNewEmail(email) { if (!canLabel()) return; const before = new Set((anchors || []).filter((x) => x.kind === "email").map((x) => x.id)); try { const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } }); const fresh = (r.anchors || []).filter((x) => x.kind === "email" && !before.has(x.id)); if (fresh.length === 1) { await setMethodLabel("email/" + fresh[0].id, email); renderMethods(); } } catch { /* the label is a nicety — the anchor itself is already in */ } } function renderMethods() { closeRowMenu(false); const box = $("methods-list"); box.textContent = ""; if (anchors && anchors.length) { const ul = document.createElement("ul"); ul.className = "methods"; for (const a of anchors) ul.append(methodRow(a)); box.append(ul); } else if (anchors) { // Can't normally happen (a signed-in account always has a way in) — a // racing removal from another device can strand a beat, though. box.append(emptyLine("No sign-in methods to show.")); } renderMethodsAdd(); } // renderMethodsAdd builds the big first-add buttons: one per LIVE kind with // zero linked entries (a linked kind's add lives in its rows' kebab). function renderMethodsAdd() { const box = $("methods-add"); box.textContent = ""; for (const k of METHOD_KINDS) { if (!k.enabled()) continue; if ((anchors || []).some((a) => a.kind === k.kind)) continue; const btn = document.createElement("button"); btn.type = "button"; btn.className = "secondary"; btn.textContent = k.first; const glyph = kindGlyph(k.kind); if (glyph) btn.prepend(glyph); btn.addEventListener("click", (e) => k.start(e)); box.append(btn); } } // The same three dots as the account kebab (static, in-code SVG). const KEBAB_SVG = ''; function methodRow(a) { const li = document.createElement("li"); const key = a.kind + "/" + a.id; li.dataset.key = key; const def = METHOD_KINDS.find((k) => k.kind === a.kind); const label = def ? def.name : a.kind; const tag = methodLabel(key); // "" when unlabelled, or the vault is sealed const added = methodAdded(a.created_at); // A labelled row leads with the label — the distinguisher earns the bold // ("1Password Eleven key" over "Passkey · added 3 Jul"); unlabelled rows // keep the kind on top. The aria name reads in the same order. const longName = (tag ? tag + ", " + label : label) + (added ? ", " + added : ""); const main = document.createElement("div"); main.className = "method-main"; const name = document.createElement("span"); name.className = "method-name"; name.textContent = tag || label; main.append(name); if (editingLabel === key) { main.append(labelEditor(key, tag, longName)); } else { const subText = (tag ? [label, added] : [added]).filter(Boolean).join(" · "); if (subText) { const sub = document.createElement("span"); sub.className = "method-added"; sub.textContent = subText; main.append(sub); } } li.append(main); if (armedRemove === key) { // The inline are-you-sure beat (never a browser confirm()): the row's // own controls become the question. const yes = document.createElement("button"); yes.type = "button"; yes.className = "pill danger-ink"; yes.textContent = "Remove"; yes.setAttribute("aria-label", "Yes, remove " + longName); yes.addEventListener("click", () => { armedRemove = ""; renderMethods(); // disarm before the (async) removal repaints focusMethodRow(key, "button"); // keep focus off mid-flight performRemove(a); }); const no = document.createElement("button"); no.type = "button"; no.className = "pill"; no.textContent = "Keep"; no.setAttribute("aria-label", "Keep " + longName); no.addEventListener("click", () => { armedRemove = ""; renderMethods(); focusMethodRow(key, "button"); }); li.append(yes, no); } else if (editingLabel !== key) { li.append(rowKebab(key, def, longName, a)); } return li; } // The inline label editor (never a browser prompt): the row's sub-line // becomes a small form. Enter saves, Escape or Cancel keeps the old label. function labelEditor(key, current, longName) { const form = document.createElement("form"); form.className = "label-edit"; const input = document.createElement("input"); input.type = "text"; input.maxLength = 60; input.value = current; input.placeholder = "e.g. personal, work"; input.setAttribute("aria-label", "Label for " + longName); const save = document.createElement("button"); save.type = "submit"; save.className = "pill"; save.textContent = "Save"; const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "pill"; cancel.textContent = "Cancel"; cancel.setAttribute("aria-label", "Cancel editing the label for " + longName); const done = () => { editingLabel = ""; renderMethods(); focusMethodRow(key, ".icon-kebab"); }; form.addEventListener("submit", async (e) => { e.preventDefault(); save.disabled = cancel.disabled = true; try { await setMethodLabel(key, input.value.trim()); done(); } catch { save.disabled = cancel.disabled = false; methodsStatus("Couldn’t save the label — try again.", "error"); } }); cancel.addEventListener("click", done); input.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); done(); } }); form.append(input, save, cancel); return form; } // rowKebab is the row's own menu — the account kebab's vocabulary scaled // down: aria-haspopup/expanded, focus onto the first item, Escape and // outside-click both close (the shared manager keeps one open at a time). // It holds "Add another" (when the kind's door is live) and Remove. function rowKebab(key, def, longName, a) { const wrap = document.createElement("div"); wrap.className = "row-kebab-wrap"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "icon-kebab"; btn.setAttribute("aria-haspopup", "menu"); btn.setAttribute("aria-expanded", "false"); btn.setAttribute("aria-label", "Options for " + longName); btn.innerHTML = KEBAB_SVG; const menu = document.createElement("div"); menu.className = "menu-pop hidden"; menu.setAttribute("role", "menu"); menu.setAttribute("aria-label", "Options for " + longName); if (def && def.enabled()) { const add = document.createElement("button"); add.type = "button"; add.setAttribute("role", "menuitem"); // Visibly just "Add another" — the row names the kind. The accessible // name carries it for menus read out of visual context ("Add another // Passkey"); label-in-name holds, the visible text is its prefix. add.textContent = "Add another"; add.setAttribute("aria-label", "Add another " + (def.name || a.kind)); add.addEventListener("click", (e) => { closeRowMenu(false); def.start(e); }); menu.append(add); } if (canLabel()) { const lab = document.createElement("button"); lab.type = "button"; lab.setAttribute("role", "menuitem"); lab.textContent = methodLabel(key) ? "Edit label" : "Add a label"; lab.setAttribute("aria-label", (methodLabel(key) ? "Edit the label for " : "Add a label for ") + longName); lab.addEventListener("click", () => { closeRowMenu(false); editingLabel = key; armedRemove = ""; renderMethods(); focusMethodRow(key, "input"); }); menu.append(lab); } const rm = document.createElement("button"); rm.type = "button"; rm.setAttribute("role", "menuitem"); rm.className = "danger-ink"; rm.textContent = "Remove"; rm.setAttribute("aria-label", "Remove " + longName); rm.addEventListener("click", () => { closeRowMenu(false); armedRemove = key; editingLabel = ""; renderMethods(); focusMethodRow(key, "button.danger-ink"); // the are-you-sure's Remove }); menu.append(rm); btn.addEventListener("pointerdown", () => { rowMenuPointerOpen = true; }); btn.addEventListener("click", () => toggleRowMenu(btn, menu)); wrap.append(btn, menu); return wrap; } // One row menu open at a time, with the account menu's open/close manners // (including its pointer-open focus-ring courtesy). let openRowMenuState = null; // { btn, menu } | null let rowMenuPointerOpen = false; function toggleRowMenu(btn, menu) { if (openRowMenuState && openRowMenuState.menu === menu) { closeRowMenu(true); return; } closeRowMenu(false); menu.classList.toggle("pointer-open", rowMenuPointerOpen); rowMenuPointerOpen = false; menu.classList.remove("hidden"); btn.setAttribute("aria-expanded", "true"); openRowMenuState = { btn, menu }; menu.querySelector('[role="menuitem"]').focus(); addEventListener("pointerdown", rowMenuOutside, true); addEventListener("keydown", rowMenuKey, true); } function closeRowMenu(refocus) { if (!openRowMenuState) return; openRowMenuState.menu.classList.add("hidden"); openRowMenuState.btn.setAttribute("aria-expanded", "false"); if (refocus) openRowMenuState.btn.focus(); openRowMenuState = null; removeEventListener("pointerdown", rowMenuOutside, true); removeEventListener("keydown", rowMenuKey, true); } function rowMenuOutside(e) { if (!e.target.closest || !e.target.closest(".row-kebab-wrap")) closeRowMenu(false); } function rowMenuKey(e) { if (e.key === "Escape") { e.preventDefault(); closeRowMenu(true); } } // focusMethodRow puts focus back where a re-render dropped it. function focusMethodRow(key, selector) { for (const li of $("methods-list").querySelectorAll("li")) { if (li.dataset.key === key) { li.querySelector(selector)?.focus(); return; } } } // performRemove: a fresh proof first — SERIALLY, via the confirm step when // the pocket is empty (list → confirm → back to the list → the DELETE) — // then doRemoveAnchor. The server's refusals — notably the last-method 409 // ("that's the last way into this account…") — show inline, verbatim. function performRemove(a) { const p = freshProof(); if (p) { doRemoveAnchor(a, p); return; } // armed: the are-you-sure was just passed, so an in-page confirm completes // the removal; the round-trip serialization drops the flag on purpose. pendingIntent = { kind: "remove", anchorKind: a.kind, id: a.id, armed: true }; openMethodsConfirmStep(); } async function doRemoveAnchor(a, proof) { methodsStatus("Removing…", "busy"); try { await apiJSON("/api/anchors/" + encodeURIComponent(a.kind) + "/" + encodeURIComponent(a.id), { method: "DELETE", headers: { "X-Ceremony-Proof": proof }, }); methodsStatus("Removed.", "ok"); setMethodLabel(a.kind + "/" + a.id, "").catch(() => { /* orphaned label — harmless */ }); } catch (e) { methodsStatus((e && e.message) || "Couldn't remove it.", "error"); } await loadAnchors(); // either way — a refusal means the list needs truth // The focused row may just have vanished — never strand focus on . if (document.activeElement === document.body) $("methods-title").focus(); } function resumeIntent() { const intent = pendingIntent; pendingIntent = null; const p = freshProof(); if (intent && p) runIntent(intent, p); } // ---- the confirm step ----------------------------------------------------------- // The confirm rungs, in fallback order; last-used (the #1112 marker) is // promoted to the front. Each rung answers "can this account actually run // it" from the anchors listing — the server takes a fresh proof from ANY // door equally (stateless.go's ceremonyUser), so the step offers every one // the account has, never passkey-or-bust. Apple doubles as the fallback for // an account whose listing hasn't loaded (the pre-anchors shape). const CONFIRM_RUNGS = ["passkey", "apple", "google", "microsoft", "github", "email"]; function confirmRungAvailable(rung) { const kinds = new Set((anchors || []).map((x) => x.kind)); if (rung === "passkey") return !!window.PublicKeyCredential && kinds.has("passkey"); if (rung === "apple") return DOORS.apple && kinds.has("siwa"); // google | microsoft | github | email — anchor kinds verbatim, gated by the // door being LIVE on this build (a dark door can't run its ceremony). return DOORS[rung] && kinds.has(rung); } // openMethodsConfirmStep is the serial "it's really you" screen, FIRST // whenever an action needs a proof the pocket doesn't hold. Every door the // account has shows, last-used first; the email rung unfolds into its code // mini-form (immediately when it's the only rung — no pointless extra tap). function openMethodsConfirmStep(note) { resetMethodsSteps(); // fresh forms; the parked change rides pendingIntent showMethodsScreen("methods-confirm"); let rungs = CONFIRM_RUNGS.filter(confirmRungAvailable); if (!rungs.length) rungs = ["apple"]; let last = ""; try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* fallback order stands */ } if (rungs.includes(last)) rungs = [last, ...rungs.filter((r) => r !== last)]; const btns = { passkey: $("methods-confirm-passkey"), apple: $("methods-confirm-apple"), google: $("methods-confirm-google"), microsoft: $("methods-confirm-microsoft"), github: $("methods-confirm-github"), email: $("methods-confirm-email"), }; for (const [rung, btn] of Object.entries(btns)) btn.classList.toggle("hidden", !rungs.includes(rung)); // DOM order is focus order — append MOVES the wired nodes into rung order. for (const rung of rungs) $("methods-confirm-actions").append(btns[rung]); const why = $("methods-confirm-why"); if (note) confirmStatus(note, "error"); if (rungs.length === 1 && rungs[0] === "email") { btns.email.classList.add("hidden"); // the one rung — straight to its form why.textContent = "Changing your sign-in options needs a quick check — enter the email address you sign in with and we'll send a code."; $("methods-cemail").classList.remove("hidden"); $("methods-cemail-addr").focus(); } else { why.textContent = rungs.length === 1 ? "Changing your sign-in options needs a quick check — confirm it's you and you'll carry straight on." : "Changing your sign-in options needs a quick check — confirm it's you with any of these and you'll carry straight on."; btns[rungs[0]].focus(); } } // The email rung's tap unfolds the code mini-form in place — the other // rungs stay, so a mind-change needs no back-and-forth. function confirmMethodsEmail() { $("methods-confirm-email").classList.add("hidden"); $("methods-cemail").classList.remove("hidden"); $("methods-cemail-addr").focus(); } async function confirmMethodsPasskey() { const btn = $("methods-confirm-passkey"); btn.disabled = true; confirmStatus("Confirming it’s you…", "busy"); try { // Only the proof is kept — this tab's session stays as it is (picking a // different account's passkey just makes the follow-on call miss, said // inline on the list). const { proof } = await runLogin(loginBegin); noteProof(proof); resumeIntent(); } catch (e) { confirmStatus(friendlyCeremonyError(e, "signin").message, "error"); } finally { prefetchLogin(); // ceremony options are single-use — ready the next btn.disabled = false; } } function confirmMethodsApple(e) { // A fresh Apple sign-in mints the proof (finishSiwa pockets it); the // markers route the return leg straight back into the flow: methodsReturn // lands on the step family, and the serialized intent RESUMES the parked // change (the add step opens, a removal re-arms) instead of the bare list. const intent = intentToken(pendingIntent); pendingIntent = null; // payloads can't survive the navigation; the token rides startAppleSignIn(e, { methodsReturn: true, intent }); } // confirmMethodsOidc — the Apple confirm through an OIDC web door: the same // marker-routed round trip (finishOidc pockets the proof, methodsReturn // lands back on the step family, the intent resumes). select_account so a // person with several provider accounts can pick the linked one. function confirmMethodsOidc(provider, e) { const intent = intentToken(pendingIntent); pendingIntent = null; // payloads can't survive the navigation; the token rides startOidcSignIn(provider, e, { methodsReturn: true, intent, select: true }); } // The confirm step's email re-verify: the same two-step form as the door, // but the answer's TOKEN is deliberately dropped — the proof is the point, // and this tab keeps the session it already has. async function cemailSend(e) { e.preventDefault(); const email = $("methods-cemail-addr").value.trim(); const btn = $("methods-cemail-send"); btn.disabled = true; btn.classList.add("loading"); confirmStatus("Sending your code…", "busy"); const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door try { await Promise.all([apiJSON("/api/email/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }), spinFloor]); confirmStatus("Check your email for a 6-digit code.", ""); $("methods-cemail-form").classList.add("hidden"); $("methods-cemail-code-form").classList.remove("hidden"); $("methods-cemail-code").focus(); } catch (err) { confirmStatus(err.message || "Couldn’t send the code — try again.", "error"); } finally { btn.disabled = false; btn.classList.remove("loading"); } } async function cemailVerify(e) { e.preventDefault(); const email = $("methods-cemail-addr").value.trim(); const code = $("methods-cemail-code").value.trim(); const btn = $("methods-cemail-verify"); btn.disabled = true; btn.classList.add("loading"); confirmStatus("Checking your code…", "busy"); try { const res = await apiJSON("/api/email/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, code }), }); noteProof(res.proof); resumeIntent(); } catch (err) { confirmStatus(err.message || "That code didn’t work — try again.", "error"); } finally { btn.disabled = false; btn.classList.remove("loading"); } } // ---- the add steps -------------------------------------------------------------- // startAddApple begins the ADD-APPLE round trip: a fresh proof first — // serially, via the confirm step when needed — then the ordinary Apple // hand-off with the link marker, so the #siwa= return posts link:true // instead of signing in (the proof rides sessionStorage beside the state; // the server holds it to the same 5-minute window). function startAddApple(e) { const src = e && e.currentTarget; const go = (proof) => startAppleSignIn( // The pressed button spins when it's still on screen (the big first-add // button); a menu-born add has no visible button left, so a detached one // absorbs the spinner and the smoke cover carries the wait. { currentTarget: src && src.isConnected ? src : document.createElement("button") }, { linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } }); const p = freshProof(); if (p) { go(p); return; } pendingIntent = { kind: "add-apple" }; openMethodsConfirmStep(); } // startAddOidc — the ADD round trip through any OIDC web door. function startAddOidc(provider, e) { const src = e && e.currentTarget; const go = (proof) => startOidcSignIn(provider, { currentTarget: src && src.isConnected ? src : document.createElement("button") }, { select: true, linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } }); const p = freshProof(); if (p) { go(p); return; } pendingIntent = { kind: "add-" + provider }; openMethodsConfirmStep(); } function startAddGoogle(e) { startAddOidc("google", e); } // startAddEmail / startAddPasskey open their serial card steps — confirm // first when the pocket is empty (list → confirm → the add step → list). function startAddEmail() { if (freshProof()) { openMethodsEmailStep(); return; } pendingIntent = { kind: "add-email" }; openMethodsConfirmStep(); } function startAddPasskey() { if (freshProof()) { openMethodsPasskeyStep(); return; } pendingIntent = { kind: "add-passkey" }; openMethodsConfirmStep(); } function openMethodsEmailStep() { resetMethodsSteps(); showMethodsScreen("methods-email"); $("methods-email-addr").focus(); } async function methodsEmailSend(e) { e.preventDefault(); const email = $("methods-email-addr").value.trim(); const btn = $("methods-email-send"); btn.disabled = true; btn.classList.add("loading"); emailStepStatus("Sending your code…", "busy"); const spinFloor = new Promise((r) => setTimeout(r, 900)); try { await Promise.all([apiJSON("/api/email/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }), spinFloor]); emailStepStatus("Check your email for a 6-digit code.", ""); $("methods-email-form").classList.add("hidden"); $("methods-email-code-form").classList.remove("hidden"); $("methods-email-code").focus(); } catch (err) { emailStepStatus(err.message || "Couldn’t send the code — try again.", "error"); } finally { btn.disabled = false; btn.classList.remove("loading"); } } async function methodsEmailVerify(e) { e.preventDefault(); const email = $("methods-email-addr").value.trim(); const code = $("methods-email-code").value.trim(); const proof = freshProof(); if (!proof) { // The proof lapsed while the code round-tripped. The code survives — // link mode gates on the proof BEFORE redeeming (email.go) — so run the // confirm step again, then retry this exact verify. pendingIntent = { kind: "verify-email", email, code }; openMethodsConfirmStep("That took a little while — confirm it’s you again."); return; } const btn = $("methods-email-verify"); btn.disabled = true; btn.classList.add("loading"); emailStepStatus("Checking your code…", "busy"); try { await finishEmailAdd(email, code, proof); } finally { btn.disabled = false; btn.classList.remove("loading"); } } // finishEmailAdd posts the link verify and lands the outcome: success goes // back to the list with the good news; a refusal shows wherever the person // is (the add step, or the list after a confirm-step retry), verbatim. The // 409 ("that address already signs in somewhere else") is the COMBINE // doorway (#1098): the error line grows the offer that opens the combine // step — keyed off the status, never off message text. async function finishEmailAdd(email, code, proof) { const res = await fetch("/api/email/verify", { method: "POST", headers: { "Content-Type": "application/json", "X-Ceremony-Proof": proof }, body: JSON.stringify({ email, code, link: true }), }); if (res.ok) { labelNewEmail(email); // fire-and-forget: the address becomes the row's E2EE label backToMethodsList("Email added — you can sign in with it now.", "ok"); return; } const msg = ((await res.text().catch(() => "")) || "").trim() || "That code didn’t work — try again."; const onEmailStep = !$("methods-email").classList.contains("hidden"); if (onEmailStep) emailStepStatus(msg, "error"); else backToMethodsList(msg, "error"); if (res.status === 409) { combine = { kind: "email", email }; showCombineOffer(onEmailStep ? "methods-email-status" : "methods-status"); } } // The add-passkey step: one button, so navigator.credentials.create fires // inside a fresh tap (Safari drops the activation across a network await). // The register options are fetched WITH the proof at step open — that's what // makes the new passkey attach to THIS account instead of minting a fresh // one (webauthn.go's register/begin). let passkeyAddBegin = null; function openMethodsPasskeyStep() { resetMethodsSteps(); showMethodsScreen("methods-passkey"); $("methods-passkey-create").focus(); prefetchPasskeyAddBegin(); } function prefetchPasskeyAddBegin() { const proof = freshProof(); if (!proof) return; apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } }) .then((v) => { passkeyAddBegin = v; }) .catch(() => { passkeyAddBegin = null; }); } async function addPasskeyCeremony() { const btn = $("methods-passkey-create"); btn.disabled = true; passkeyStepStatus("Creating your passkey…", "busy"); let begin = passkeyAddBegin; passkeyAddBegin = null; // ceremony options are single-use try { if (!begin) { const proof = freshProof(); if (!proof) { // Sat on this step past the window — the confirm step runs again, // then reopens here with fresh proof-carrying options. pendingIntent = { kind: "add-passkey" }; openMethodsConfirmStep("That took a little while — confirm it’s you again."); return; } // The prefetch missed (slow network); fetching here may cost Safari's // activation — the retry tap then uses the fresh prefetch below. begin = await apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } }); } const reg = await runRegister(begin); noteProof(reg.proof); // When this tab holds the DEK and a blob already exists, wrap it for the // new credential too — the passkey then OPENS the list, not just signs // in. Sealed/anchor tabs (and blob-less accounts — an empty vault save // would trip the creation-eligibility gate, see createPasskey) skip it: // identity-only is still a real way in under the anchor model. if (dekKey && vault && vault.version !== "0" && reg.prf) { try { const kek = await kekFromPRF(reg.prf); vault.keys[reg.credId] = await wrapDEK(dekKey, kek); await saveVault(); } catch { /* the passkey works for sign-in; the wrap can land later */ } } backToMethodsList("Passkey added — one tap next time on this device.", "ok"); } catch (e) { prefetchPasskeyAddBegin(); // options were spent — ready a retry if (e && e.message === "no-prf") { passkeyStepStatus("This device can’t protect your sign-in here (no PRF support) — try a different device or password manager.", "error"); } else { passkeyStepStatus(formatCeremonyError(e, "create"), "error"); } } finally { btn.disabled = false; } } // ---- combine your accounts (#1098's merge) -------------------------------------- // The in-flight combine context, memory-only: which anchor kind collided // ("email" | "apple"), the typed address (email kind), and — once stage A // proves the other side — the ABSORBED account's credentials. Those are // deliberately never adopted as the session and never persisted: this tab // stays signed into the SURVIVOR; the absorbed token+proof exist only to // move its vault and feed absorb_proof to POST /api/account/merge // (merge.go). Cancel anywhere just drops this object — nothing destructive // happens before the explicit beats. let combine = null; // { kind, email?, absorbedToken?, absorbedProof?, absorbedExpires?, absorbedBlob?, absorbedPasskeyIds? } function combineStatus(msg, kind) { const el = $("combine-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } // showCombineOffer grows the offer under whichever status line carries the // 409 — the node moves beside it (like #newhere follows its surface) and // resets hide it again. function showCombineOffer(statusId) { $(statusId).insertAdjacentElement("afterend", $("combine-offer")); $("combine-offer").classList.remove("hidden"); } // absorbedProofFresh mirrors freshProof for the OTHER side's proof — the // merge and the absorbed-blob delete both refuse a stale one server-side, // so a lapsed proof re-runs the prove stage instead of burning a call. function absorbedProofFresh() { return !!(combine && combine.absorbedProof && combine.absorbedExpires - PROOF_MARGIN_MS > Date.now()); } // openCombineStep is the serial combine screen. Stage A (prove the other // account) when the absorbed side is still unproven; a proven side goes // straight to stage B (its spaces). The Apple prove is a full round trip // (merge-apple intent), so its return re-enters here already proven. function openCombineStep() { if (!combine) { backToMethodsList("", "", { reload: false }); return; } resetMethodsSteps(); showMethodsScreen("methods-combine"); $("combine-title").focus(); if (absorbedProofFresh()) { combineVaultStage(); return; } combine.absorbedProof = ""; // a stale proof re-proves from scratch $("combine-prove").classList.remove("hidden"); if (combine.kind === "email") { // The add-flow's code was spent by the 409 (the redeem happens before // the conflict answer), so a FRESH code proves the other side — say so. $("combine-prove-note").textContent = "First, prove the other account is yours: we’re sending another code to " + combine.email + "."; $("combine-code").classList.remove("hidden"); combineRequestCode(); $("combine-code-input").focus(); } else if (combine.kind === "apple") { // The button says it all (Paul: no "First, prove…" preamble); only the // email prove keeps its note — it names where the code went. $("combine-prove-note").textContent = ""; $("combine-apple-go").classList.remove("hidden"); $("combine-apple-go").focus(); } else { // A provider kind (google/microsoft/github): one generic go button, // dressed per provider — brand class, display name, cloned glyph. const p = OIDC_WEB[combine.kind]; $("combine-prove-note").textContent = ""; const go = $("combine-oidc-go"); go.className = { google: "google-btn", microsoft: "ms-btn", github: "gh-btn" }[combine.kind]; go.textContent = "Continue with " + p.display; const glyph = kindGlyph(combine.kind); if (glyph) go.prepend(glyph); go.focus(); } } async function combineRequestCode() { combineStatus("Sending a code…", "busy"); try { await apiJSON("/api/email/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: combine.email }), }); combineStatus("We sent another code to prove that account is yours — check your email.", ""); } catch (e) { combineStatus((e && e.message) || "Couldn’t send the code — try again.", "error"); } } // The prove-by-code verify runs in SIGN-IN mode (link:false): the answer is // the absorbed account's token + proof, held in memory only (see combine). async function combineCodeVerify(e) { e.preventDefault(); const code = $("combine-code-input").value.trim(); const btn = $("combine-code-verify"); btn.disabled = true; btn.classList.add("loading"); combineStatus("Checking your code…", "busy"); const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door try { const [res] = await Promise.all([apiJSON("/api/email/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: combine.email, code }), }), spinFloor]); combine.absorbedToken = res.token; combine.absorbedProof = res.proof; combine.absorbedExpires = Date.now() + PROOF_TTL_MS; combineStatus(""); combineVaultStage(); } catch (err) { combineStatus((err && err.message) || "That code didn’t work — try again.", "error"); } finally { btn.disabled = false; btn.classList.remove("loading"); } } // The prove-by-Apple leg: a full round trip carrying the merge-apple intent; // finishSiwaMergeProve picks the return up and re-enters the step proven. function combineAppleProve(e) { startAppleSignIn(e, { intent: "merge-apple" }); } // kindGlyph clones a kind's brand mark from its all-ways button — one // source per glyph (static, in-code SVG), sized for inline button use. function kindGlyph(kind) { const src = $("otherways-" + (kind === "siwa" ? "apple" : kind)); const svg = src && src.querySelector("svg"); if (!svg) return null; const c = svg.cloneNode(true); c.classList.remove("pk-ico"); c.classList.add("btn-ico"); return c; } // Stage B: the absorbed account's saved spaces. Nothing to move (no blob) // skips straight to the merge; a real blob offers the honest choices — // unlock with ITS passkey and bring them over, or (behind an inline // are-you-sure) combine without them, destroying its sealed list. async function combineVaultStage() { $("combine-prove").classList.add("hidden"); $("combine-code").classList.add("hidden"); $("combine-apple-go").classList.add("hidden"); $("combine-oidc-go").classList.add("hidden"); $("combine-skip-confirm").classList.add("hidden"); combineStatus("Checking the other account’s spaces…", "busy"); let got; try { got = await fetchBlob(combine.absorbedToken); if (got) { const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + combine.absorbedToken } }); combine.absorbedPasskeyIds = (r.anchors || []).filter((x) => x.kind === "passkey").map((x) => x.id); } } catch (e) { combineStatus((e && e.message) || "Couldn’t reach the other account — try again.", "error"); return; } if (!got) { combineFinish(0); return; } // (a) nothing to move combine.absorbedBlob = got; combineStatus(""); const canCeremony = !!window.PublicKeyCredential && combine.absorbedPasskeyIds.length > 0; // The union needs the SURVIVOR side open too: its DEK in hand and at least // one wrapped copy of it to seal under (a keys-less PUT would mint a blob // nobody could ever open). const canUnion = !!dekKey && !!vault && Object.keys(vault.keys || {}).length > 0; $("combine-vault").classList.remove("hidden"); $("combine-unlock").classList.toggle("hidden", !(canCeremony && canUnion)); $("combine-skip").classList.remove("hidden"); const note = $("combine-vault-note"); if (canCeremony && canUnion) { note.textContent = "The other account has saved spaces. Unlock them with that account’s passkey and they’ll move over with everything else."; $("combine-unlock").focus(); prefetchLogin(); // the unlock ceremony wants gesture-fresh options } else if (!canUnion) { note.textContent = "The other account has saved spaces, but your own spaces are locked right now, so they can’t be brought over. Go back and unlock your spaces with your passkey first — or combine without its spaces."; $("combine-skip").focus(); } else { note.textContent = "The other account has saved spaces, but it has no passkey to unlock them, so they can’t be brought over."; $("combine-skip").focus(); } } // combineUnlock runs the ABSORBED account's passkey ceremony, decrypts its // vault, unions it into the survivor's (survivor wins ties, tombstones from // either side hold, exactly saveVault's race rules), re-encrypts under the // survivor's DEK, deletes the absorbed blob, then merges. async function combineUnlock() { const btn = $("combine-unlock"); btn.disabled = true; combineStatus("Confirm with the OTHER account’s passkey…", "busy"); let fin; try { fin = await runLogin(loginBegin); } catch (e) { combineStatus(friendlyCeremonyError(e, "signin").message, "error"); prefetchLogin(); btn.disabled = false; return; } prefetchLogin(); // options were spent either way // The ceremony MUST land on the absorbed account — its credential ids are // exactly the passkey ids its anchors listing gave us. A miss means the // wrong passkey was picked (likely this account's own): explain and retry. if (!combine.absorbedPasskeyIds.includes(fin.credId)) { combineStatus("That passkey opens a different account — choose the OTHER account’s passkey and try again.", "error"); btn.disabled = false; return; } // Freshest absorbed credentials win (the ceremony just minted a proof). combine.absorbedToken = fin.token; combine.absorbedProof = fin.proof; combine.absorbedExpires = Date.now() + PROOF_TTL_MS; try { if (!fin.prf) throw new Error("That passkey didn’t return the key material needed to unlock (no PRF) — try another device, or combine without its spaces."); const kek = await kekFromPRF(fin.prf); const wrapped = unpackBlob(combine.absorbedBlob.bytes).keys[fin.credId]; if (!wrapped) throw new Error("That passkey can’t unlock the other account’s spaces."); const absDek = await unwrapDEK(wrapped, kek); const opened = await openBlob(combine.absorbedBlob.bytes, absDek); combineStatus("Bringing its spaces over…", "busy"); // Union under saveVault's rules: absorbed first, survivor overlays // (survivor wins ties by url); tombstones from either side hold unless // the SURVIVOR's live list still carries the entry. const before = (vault.obj.servers || []).length; const merged = new Map((opened.obj.servers || []).map((sv) => [sv.url, sv])); for (const sv of vault.obj.servers || []) merged.set(sv.url, sv); const removed = mergeRemoved(opened.obj.removed, vault.obj.removed) .filter((t) => !(vault.obj.servers || []).some((sv) => sv.url === t.url)); vault.obj.servers = [...merged.values()].filter((sv) => !removed.some((t) => t.url === sv.url)); if (removed.length) vault.obj.removed = removed; else delete vault.obj.removed; // Method labels ride the union too: absorbed first, survivor overlays — // the moved-over anchors keep the names their old account gave them. const labels = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) }; if (Object.keys(labels).length) vault.obj.methods = labels; const movedSpaces = vault.obj.servers.length - before; // The absorbed passkey just proved AND unlocked — after the merge it // signs into the survivor, so wrap the survivor's DEK for it too: it // should OPEN the combined list, not hit the sealed prompt. vault.keys[fin.credId] = await wrapDEK(dekKey, kek); await saveVault(); // CAS PUT of the survivor's union, retrying once await deleteAbsorbedBlob(); // client-first ordering: emptied before merge combineFinish(movedSpaces); } catch (e) { combineStatus((e && e.message) || "Couldn’t bring its spaces over.", "error"); btn.disabled = false; } } // The skip path's inline are-you-sure: destroying the absorbed account's // sealed spaces list must be an explicit, plainly-worded beat of its own. function combineSkip() { $("combine-unlock").classList.add("hidden"); $("combine-skip").classList.add("hidden"); $("combine-skip-confirm").classList.remove("hidden"); $("combine-skip-yes").focus(); } async function combineSkipYes() { const btn = $("combine-skip-yes"); btn.disabled = true; combineStatus("Clearing its spaces list…", "busy"); try { await deleteAbsorbedBlob(); combineFinish(0); } catch (e) { combineStatus((e && e.message) || "Couldn’t clear the other account’s spaces list.", "error"); } finally { btn.disabled = false; } } // deleteAbsorbedBlob is the ceremony-gated DELETE /api/blob, authed by the // ABSORBED side's proof — merge.go's precondition (client-first ordering). async function deleteAbsorbedBlob() { if (!absorbedProofFresh()) { openCombineStep(); // stale other-side proof: prove again, then retry combineStatus("That took a while — prove the other account again first.", "error"); throw new Error("prove the other account again first"); } const res = await fetch("/api/blob", { method: "DELETE", headers: { "X-Ceremony-Proof": combine.absorbedProof } }); if (!res.ok && res.status !== 404) { throw new Error(((await res.text().catch(() => "")) || "").trim() || "Couldn’t clear the other account’s spaces list."); } } // Stage C: the merge itself — both sides' fresh proofs (merge.go's // both-sides-proofs auth), then the plain-words outcome back on the list. async function combineFinish(movedSpaces = 0) { if (!combine) { backToMethodsList("", "", { reload: false }); return; } const sp = freshProof(); if (!sp) { // The SURVIVOR's proof lapsed mid-combine: the confirm step runs, then // finishes right here (memory-only intent — a confirm-with-Apple trip // would lose the combine context, and intentToken serializes this to // nothing on purpose). pendingIntent = { kind: "combine-finish", movedSpaces }; openMethodsConfirmStep("That took a while — confirm it’s you again to finish combining."); return; } if (!absorbedProofFresh()) { openCombineStep(); combineStatus("That took a while — prove the other account again first.", "error"); return; } showMethodsScreen("methods-combine"); combineStatus("Combining the accounts…", "busy"); let res; try { res = await fetch("/api/account/merge", { method: "POST", headers: { Authorization: "Bearer " + state.token, "X-Ceremony-Proof": sp, "Content-Type": "application/json", }, body: JSON.stringify({ absorb_proof: combine.absorbedProof }), }); } catch { combineStatus("Couldn’t reach the server — try again.", "error"); return; } if (!res.ok) { // The server's plain refusals verbatim (same-account, still-holds-a- // backup, a stale proof), landed on this step for another go. combineStatus(((await res.text().catch(() => "")) || "").trim() || "Couldn’t combine the accounts.", "error"); return; } const body = await res.json().catch(() => ({})); const movedMethods = (body.moved && (body.moved.anchors || 0) + (body.moved.passkeys || 0)) || 0; let msg = "Accounts combined — " + movedMethods.toLocaleString() + " sign-in method" + (movedMethods === 1 ? "" : "s") + " moved"; if (movedSpaces > 0) { msg += ", " + movedSpaces.toLocaleString() + " space" + (movedSpaces === 1 ? "" : "s") + " brought over"; } msg += "."; combine = null; backToMethodsList(msg, "ok"); // reloads the anchors — the moved methods appear if (movedSpaces > 0 && vault) provisioned = null; // the ledger may know the new spaces } // ---- spaces list -------------------------------------------------------------- // hideVault parks the spaces screen for a sibling screen (or sign-out). The // siwa-arrival class comes off HERE, under display:none where it's free — // removing it while the vault is visible changes the computed animation-name // back to .screen's, and a changed name RESTARTS the entry animation on an // already-visible card (Paul's after-flash). An open account menu doesn't // survive the screen either. function hideVault() { closeAccountMenu(false); $("vault").classList.add("hidden"); $("vault").classList.remove("arrive"); } // Creating a space is for people already IN one: the + hides until the // decrypted vault actually holds a space entry (an empty account's + only // invited the server's 403 dead-end — creation is gated to members, and the // sealed and empty states both hide it). The server enforces the same // boundary on POST /api/spaces. function canCreateSpace() { return spacesEnabled && !!(vault && (vault.obj.servers || []).length); } function showVault() { if (consumeJoinReturn()) return; // pre-join sign-in: back to the join page, not the list $("signed-out").classList.add("hidden"); $("reassure").classList.add("hidden"); resetEmailDoor(); // arriving from the email door hides it resetOtherWays(); // …and from the all-ways step likewise $("create").classList.add("hidden"); $("archive").classList.add("hidden"); $("vault-sealed").classList.add("hidden"); $("vault").classList.remove("hidden"); $("open-create").classList.toggle("hidden", !canCreateSpace()); // the + to create const box = $("spaces"); box.textContent = ""; // Archived spaces are pulled out first — hidden from the main list, reachable // through the "Archived (N)" affordance at the foot. const { active, archived } = partitionArchived(vault.obj.servers || []); // A just-archived space leaves an inline "Archived · Undo" banner (never a // floating toast); it survives re-renders and auto-clears on a timer. if (pendingUndo) box.append(undoBanner()); // Fuzzy search filters the active lists. It only appears once there's enough // to sift through; while a query is active, reordering is off (dragging a // filtered subset would be ambiguous) so the grips are hidden. const search = $("space-search"); search.classList.toggle("hidden", active.length < 4); const query = search.classList.contains("hidden") ? "" : search.value.trim(); // The invite-only card belongs only to the truly-empty state — someone // with archived spaces already has Eleven. $("invite-note").classList.toggle("hidden", !!(active.length || archived.length)); if (!active.length) { box.append(emptyLine(archived.length ? "All your spaces are archived — open the archive to bring one back." : "None (yet). Creating is invite-only for now.")); // Truly empty + signed in through an anchor: this may be the wrong-door // landing — their spaces could live under an older passkey account. const m = sessionMethod(); if (!archived.length && m && m !== "passkey" && window.PublicKeyCredential) box.append(bringOverLine()); } else { const servers = query ? filterServers(active, query) : active; if (!servers.length) { box.append(emptyLine("No spaces match “" + query + "”.")); } else { const sortable = !query; // Two independently-sorted lists: Starred (→ the app's rail) and the rest // (→ a "more" affordance). Headings appear only once something's starred. const { starred, others } = partitionServers(servers); if (starred.length) { box.append(sectionHeading("Starred"), serverList(starred, sortable)); if (others.length) box.append(sectionHeading("More")); } if (others.length) box.append(serverList(others, sortable)); } } if (archived.length) box.append(archivedButton(archived.length)); // The ledger read behind the Upgrade affordance — lazy, once per sign-in. if (spacesEnabled && provisioned === null && state.token) refreshProvisioned(); // Back from a pay-first checkout — or a marker parked by a tab that closed // on Stripe's page: claim the space into existence. Once per session id per // page life (the error pane's Try again drives retries); the claim itself // is idempotent downstream, so a duplicate is harmless. { const marker = pendingCreateMarker(); const session = paidCreateSession || (marker && marker.session) || ""; const region = paidCreateRegion || (marker && marker.region) || ""; paidCreateSession = ""; paidCreateRegion = ""; if (state.token && session && !claimedSessions.has(session)) { claimedSessions.add(session); claimPaidCreate(session, region); return; // the claim owns the screen now } } // Back from a completed checkout: thank inline, then re-read the ledger a few // seconds later — the webhook that flips the badge can lag the redirect. if (upgradedHost) { vaultStatus("Payment received — " + upgradedHost + " is upgraded. Thank you!", "ok"); upgradedHost = ""; setTimeout(() => { provisioned = null; if (vault && !$("vault").classList.contains("hidden")) showVault(); }, 5000); } // A pending #claim= deep link resumes here, the first vault render // after sign-in — it needs only the account, not the provisioner ledger. consumeClaimLink(); } // refreshProvisioned learns which spaces this account created and their // entitlement (the provisioner's ledger, via home). No answer just means no // Upgrade affordance — the list renders fine without it. function refreshProvisioned() { provisioned = {}; // in flight — re-renders don't refetch apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } }) .then((r) => { billingTiers = (r.billing && r.billing.tiers) || []; billingEnabled = billingTiers.length > 0; provisioned = {}; for (const sp of r.spaces || []) provisioned[sp.host] = sp; if (vault && !$("vault").classList.contains("hidden")) showVault(); // The create plan step renders its picker from this book — fill it in // if the screen opened before the ledger answered. if (!$("create-plan").classList.contains("hidden")) renderPlanTiers(); consumeUpgradeLink(); }) .catch(() => { /* ledger unreachable — render without the affordance */ }); } // consumeUpgradeLink honours a #upgrade= deep link (the space menu's // Upgrade entry) once the ledger is in: opens the tier picker for that space // if it's this account's and still free. Cleared either way, so a stale // fragment can't re-fire the sheet on later renders. function consumeUpgradeLink() { if (!upgradeLinkHost || !vault) return; const target = upgradeLinkHost; upgradeLinkHost = ""; history.replaceState(null, "", location.pathname + location.search); const s = (vault.obj.servers || []).find((sv) => { try { return new URL(sv.url).host.toLowerCase() === target; } catch { return false; } }); const ledger = provisioned && provisioned[target]; if (s && ledger && ledger.entitlement !== "paid") openUpgrade(s, true); } // ---- archive: undo banner + entry point -------------------------------------- // pendingUndo holds the pre-archive servers snapshot so a single tap fully // restores the entry (its star + exact position), not just the archived flag. let pendingUndo = null; // { name, prev } | null let undoTimer = 0; function clearUndo() { if (undoTimer) { clearTimeout(undoTimer); undoTimer = 0; } pendingUndo = null; } function undoBanner() { const p = document.createElement("p"); p.className = "undo-note"; const span = document.createElement("span"); span.textContent = "Archived " + pendingUndo.name + ". "; const btn = document.createElement("button"); btn.type = "button"; btn.className = "linkish"; btn.textContent = "Undo"; btn.addEventListener("click", undoArchive); p.append(span, btn); return p; } // A muted archive-box glyph (static, in-code SVG — safe for innerHTML), matching // the quiet grip: a lidded box with a handle slot. const ARCHIVE_SVG = ''; // The foot-of-list button that opens the archive; shown only when something's in // it. The count keeps the archive discoverable without cluttering the main list. function archivedButton(n) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "archived-open"; btn.addEventListener("click", openArchive); const label = document.createElement("span"); label.innerHTML = ARCHIVE_SVG; const text = document.createElement("span"); text.textContent = "Archived (" + n + ")"; label.append(text); const chev = document.createElement("span"); chev.className = "chev"; chev.setAttribute("aria-hidden", "true"); chev.textContent = "›"; btn.append(label, chev); return btn; } function archiveSpace(s) { clearUndo(); const prev = vault.obj.servers; pendingUndo = { name: labelOf(s), prev }; // set before applyServers so the banner renders undoTimer = setTimeout(() => { pendingUndo = null; if (vault) showVault(); }, 8000); applyServers(setArchived(prev, s.url, true)); // re-renders (with the banner) + persists } function undoArchive() { if (!pendingUndo) return; const prev = pendingUndo.prev; clearUndo(); applyServers(prev); // restore the exact prior array (star + position), re-render, persist } function emptyLine(text) { const p = document.createElement("p"); p.className = "empty"; p.textContent = text; return p; } // Which method is holding THIS session up — the persisted session records it; // the last-signin marker covers a tab-only (unpersisted) session. "" when // unknowable. function sessionMethod() { const s = persistedSession(); if (s && s.method) return s.method; try { return localStorage.getItem("home_last_signin") || ""; } catch { return ""; } } // The wrong-door landing: an Apple/email sign-in that resolves a brand-new // account shows an empty list — but this person's spaces may live under the // passkey they used before anchors existed. Offer the bridge in place: the // ordinary passkey door, run from the signed-in view. signIn() touches no // state until the ceremony succeeds, so cancelling leaves this session // exactly as it was; success swaps to the passkey account, where Sign-in // options → Add pulls this anchor over (the combine flow). function bringOverLine() { const p = document.createElement("p"); p.className = "empty"; const b = document.createElement("button"); b.type = "button"; b.className = "passkey-link"; b.textContent = "Sign in with your passkey"; b.addEventListener("click", () => { b.disabled = true; vaultStatus("Confirming it’s you…", "busy"); signIn().then(() => vaultStatus(""), () => { vaultStatus("That passkey didn’t confirm — nothing has changed.", "error"); }).finally(() => { b.disabled = false; setSignedOutBusy(false); setStatus(""); }); }); p.append("Already have spaces? ", b, " to bring them over."); return p; } function sectionHeading(text) { const h = document.createElement("div"); h.className = "spaces-head"; h.textContent = text; return h; } function serverList(group, sortable) { const ul = document.createElement("ul"); ul.className = "servers"; for (const s of group) ul.append(serverRow(s, sortable)); return ul; } // A quiet 6-dot grip (static, in-code SVG — safe for innerHTML). iOS-style, it // lives on the right so the star stays the left anchor; it surfaces on hover. const GRIP_SVG = ''; function serverRow(s, sortable) { const li = document.createElement("li"); li.dataset.url = s.url; const star = document.createElement("button"); star.type = "button"; star.className = "star" + (s.starred ? " on" : ""); star.textContent = s.starred ? "★" : "☆"; star.setAttribute("aria-label", (s.starred ? "Unstar " : "Star ") + labelOf(s)); // Star/reorder act on the ACTIVE list only; archived entries ride along at the // tail untouched (they never render a star or grip). star.addEventListener("click", () => { const { active, archived } = partitionArchived(vault.obj.servers); applyServers([...toggleStar(active, s.url), ...archived]); }); const main = document.createElement("div"); main.className = "srv-main"; const name = document.createElement("a"); name.className = "name"; // 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); // Spaces go by name here — the address stays off the row. It survives as a // hover tooltip, which also tells twin-named spaces apart. (Unnamed spaces // already show their host as the label, so the tooltip would just repeat it.) if (s.name) name.title = hostOf(s.url); main.append(name); // Billing state, for spaces this account created (the provisioner's ledger): // upgraded rows carry a quiet Supporter badge; free ones offer Upgrade. const ledger = billingEnabled && provisioned ? provisioned[hostOf(s.url)] : null; if (ledger && ledger.entitlement === "paid") { // The tier badge with Manage right beside it — the subscription's label // and its controls read as one unit. const row = document.createElement("span"); row.className = "badge-row"; const badge = document.createElement("span"); badge.className = "supporter-badge"; badge.textContent = tierName(ledger.tier); const manage = document.createElement("button"); manage.type = "button"; manage.className = "row-manage"; manage.textContent = "Manage"; manage.setAttribute("aria-label", "Manage the subscription for " + labelOf(s)); manage.addEventListener("click", () => openPortal(hostOf(s.url), manage)); row.append(badge, manage); // A cancelled subscription runs out at paid_until — say so, quietly. if (ledger.cancel_at_period_end && ledger.paid_until) { const ends = document.createElement("span"); ends.className = "sub-ends"; ends.textContent = "ends " + new Date(ledger.paid_until * 1000) .toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); row.append(ends); } main.append(row); } // The storage meter's reading (spaces this account created; 0 = not yet // metered), with the space's storage allowance when one is set. const metered = provisioned ? provisioned[hostOf(s.url)] : null; if (metered && metered.storage_bytes > 0) { const usage = document.createElement("span"); usage.className = "srv-usage"; usage.textContent = metered.storage_cap > 0 ? fmtBytes(metered.storage_bytes) + " of " + fmtBytes(metered.storage_cap) + " stored" : fmtBytes(metered.storage_bytes) + " stored"; main.append(usage); } li.append(star, main); if (ledger && ledger.entitlement !== "paid") { const up = document.createElement("button"); up.type = "button"; up.className = "row-upgrade"; up.textContent = "Upgrade"; up.setAttribute("aria-label", "Upgrade " + labelOf(s)); up.addEventListener("click", () => openUpgrade(s)); li.append(up); } // Archive: a quiet box glyph that reveals on hover (like the grip). One tap // sets it aside (reversible via the inline Undo); no confirmation — the archive // is where the permanent, strongly-worded delete lives. const arch = document.createElement("button"); arch.type = "button"; arch.className = "row-archive"; arch.setAttribute("aria-label", "Archive " + labelOf(s)); arch.innerHTML = ARCHIVE_SVG; arch.addEventListener("click", () => archiveSpace(s)); li.append(arch); // The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) — // the a11y reorder fallback. Omitted while a search filter is active (you can't // meaningfully reorder a filtered subset). if (sortable) { const handle = document.createElement("button"); handle.type = "button"; handle.className = "drag-handle"; handle.setAttribute("aria-label", "Reorder " + labelOf(s)); handle.innerHTML = GRIP_SVG; handle.addEventListener("pointerdown", (e) => startDrag(e, li)); handle.addEventListener("keydown", (e) => { const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0; if (!dir) return; e.preventDefault(); const { active, archived } = partitionArchived(vault.obj.servers); applyServers([...moveInGroup(active, s.url, dir), ...archived]); focusHandle(s.url); // re-render dropped focus; put it back on the moved row }); li.append(handle); } return li; } function focusHandle(url) { for (const li of $("spaces").querySelectorAll("li")) { if (li.dataset.url === url) { li.querySelector(".drag-handle")?.focus(); return; } } } // ---- drag to reorder ---------------------------------------------------------- // Pointer-based (mouse + touch): the grabbed row lifts and follows the pointer // while its siblings slide aside (FLIP). Drag is confined to its own list, so the // Starred and other lists sort independently; the star toggle moves rows between // them. prefers-reduced-motion drops the slide + settle animations. let drag = null; function startDrag(e, li) { if (e.pointerType === "mouse" && e.button !== 0) return; e.preventDefault(); const handle = e.currentTarget; const rect = li.getBoundingClientRect(); drag = { li, ul: li.parentElement, handle, pointerId: e.pointerId, grab: e.clientY - rect.top, moved: false }; handle.setPointerCapture(e.pointerId); li.classList.add("dragging"); handle.addEventListener("pointermove", onDragMove); handle.addEventListener("pointerup", endDrag); handle.addEventListener("pointercancel", endDrag); } function onDragMove(e) { if (!drag) return; drag.moved = true; const { li, ul } = drag; // Which sibling should we sit above? The first whose midpoint is below us. let ref = null; for (const r of ul.children) { if (r === li || r.tagName !== "LI") continue; const rc = r.getBoundingClientRect(); if (e.clientY < rc.top + rc.height / 2) { ref = r; break; } } const changes = ref ? li.nextElementSibling !== ref : ul.lastElementChild !== li; if (changes) flipReorder(ul, li, () => (ref ? ul.insertBefore(li, ref) : ul.append(li))); // Glue the lifted row under the pointer (clear transform first to read its // natural post-reorder position). li.style.transition = "none"; li.style.transform = ""; const top = li.getBoundingClientRect().top; li.style.transform = "translateY(" + (e.clientY - drag.grab - top) + "px)"; } function endDrag() { if (!drag) return; const { li, handle, pointerId, moved } = drag; handle.releasePointerCapture?.(pointerId); handle.removeEventListener("pointermove", onDragMove); handle.removeEventListener("pointerup", endDrag); handle.removeEventListener("pointercancel", endDrag); li.style.transition = reducedMotion() ? "none" : "transform 160ms ease"; li.style.transform = ""; // settle back into the slot the finger left it in li.classList.remove("dragging"); drag = null; if (moved) commitOrder(); } // flipReorder runs `mutate` (the DOM reorder) and animates every row it displaced // from its old position to its new one — the "slide aside" that makes the reorder // read as physical rather than a jump. The dragged row is excluded (it's glued to // the pointer separately). function flipReorder(ul, exclude, mutate) { if (reducedMotion()) { mutate(); return; } const rows = [...ul.children].filter((el) => el.tagName === "LI" && el !== exclude); const before = new Map(rows.map((el) => [el, el.getBoundingClientRect().top])); mutate(); for (const el of rows) { const dy = before.get(el) - el.getBoundingClientRect().top; if (!dy) continue; el.style.transition = "none"; el.style.transform = "translateY(" + dy + "px)"; requestAnimationFrame(() => { el.style.transition = "transform 160ms ease"; el.style.transform = ""; }); } } // commitOrder reads the on-screen order back into the vault and saves — only if it // actually changed (a grab-without-move shouldn't write). function commitOrder() { const order = []; for (const ul of $("spaces").querySelectorAll("ul.servers")) { for (const li of ul.querySelectorAll("li")) order.push(li.dataset.url); } const { active, archived } = partitionArchived(vault.obj.servers || []); const next = orderServers(active, order); if (next.length === active.length && next.every((s, i) => s.url === active[i].url)) return; vault.obj.servers = [...next, ...archived]; persistVault(); } function reducedMotion() { return matchMedia("(prefers-reduced-motion: reduce)").matches; } // applyServers swaps in a new servers array (star/keyboard-move returns a fresh // one), re-renders immediately (optimistic), then persists in the background. function applyServers(next) { vault.obj.servers = next; showVault(); persistVault(); } async function persistVault(status = vaultStatus) { status("Saving…", "busy"); try { await saveVault(); status("Saved.", "ok"); } catch (e) { status(e.message || "Couldn't save your changes.", "error"); } } // ---- upgrade a space ---------------------------------------------------------- function upgradeStatus(msg, kind) { const el = $("upgrade-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } // tierName maps a ledger tier key to its member-facing name; spaces upgraded // under a tier that's since left the price book still get a badge. function tierName(key) { const t = billingTiers.find((x) => x.key === key); return (t && t.name) || "Supporter"; } // The picker's band + ≈ state. The band resets to base (or a deep link) per // open; the currency override is session-only — nothing about the buyer is // stored, matching the honor design (docs/pricing-bands.md). let selectedBand = "base"; let approxCurrency = ""; // "" = follow the browser locale; "EUR" = hide ≈ let fxSnapshot = null; // {date, rates} once /api/fx answers; null before // openUpgrade shows the plan picker for one space. The payment itself is // Stripe's hosted page; this screen only chooses the plan and hands off. // fromLink keeps the deep link's &tier= highlight; a manual open drops it. function openUpgrade(s, fromLink) { if (!fromLink) tierLink = ""; hideVault(); $("upgrade").classList.remove("hidden"); $("upgrade-host").textContent = hostOf(s.url); upgradeStatus(""); if (bandLink) { selectedBand = bandLink; bandLink = ""; history.replaceState(null, "", location.pathname + location.search); } // The ≈ rates: one fetch per page life, rendered in when they land. The // picker is complete without them — an absent estimate beats a wrong one. if (!fxSnapshot) { fxSnapshot = { date: "", rates: null }; apiJSON("/api/fx") .then((b) => { fxSnapshot = { date: b.date || "", rates: b.rates || null }; if (!$("upgrade").classList.contains("hidden")) renderUpgrade(s); }) .catch(() => { /* no rates — the EUR stickers stand alone */ }); } renderUpgrade(s); } // renderUpgrade paints the whole picker for the selected band: the band row // (only when the price book actually offers bands), each tier at that band's // price with its ≈ local figure, the honor line, and the +15% review promise. function renderUpgrade(s) { const offered = bandsOffered(billingTiers); const banded = offered.length > 1; if (!offered.includes(selectedBand)) selectedBand = "base"; const row = $("upgrade-bands"); row.textContent = ""; row.classList.toggle("hidden", !banded); if (banded) { for (const key of offered) { const chip = document.createElement("button"); chip.type = "button"; chip.className = "band-chip" + (key === selectedBand ? " selected" : ""); chip.setAttribute("aria-pressed", key === selectedBand ? "true" : "false"); chip.textContent = bandName(key); chip.addEventListener("click", () => { selectedBand = key; renderUpgrade(s); }); row.append(chip); } } const note = $("upgrade-band-note"); note.textContent = banded ? BANDS.find((b) => b.key === selectedBand).line + " Pick the price that's right for you — no questions asked." : ""; const currency = approxCurrency || regionCurrency(navigator.language); const rates = fxSnapshot && fxSnapshot.rates; const box = $("upgrade-tiers"); box.textContent = ""; // The listing said this space isn't paid, but its region advertised no // tiers to sell (billing not configured there, or momentarily unreachable) // — say so plainly rather than leaving a silent gap where the plan // buttons belong (openUpgrade only gets here once ownership + free-tier // are already confirmed, so the sheet itself is warranted). if (!billingTiers.length) { upgradeStatus("Upgrading isn't available for this space right now.", "error"); $("upgrade-fx").classList.add("hidden"); $("upgrade-cap").textContent = ""; return; } let anyApprox = false; for (const t of billingTiers) { const view = tierForBand(t, selectedBand); const btn = document.createElement("button"); btn.type = "button"; btn.className = "tier-btn"; const name = document.createElement("span"); name.className = "tier-name"; name.textContent = t.name; const price = document.createElement("span"); price.className = "tier-price"; // Business stickers are ex-VAT wherever they appear (docs/pricing-bands.md § VAT). price.textContent = view.label + (t.business ? " excl. VAT" : ""); btn.append(name, price); const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language); if (approx) { anyApprox = true; const a = document.createElement("span"); a.className = "tier-approx"; a.textContent = approx; btn.append(a); } // A tier without the selected band sells at its standard price — said // plainly, never implied to be reduced. if (banded && !view.offered) { const std = document.createElement("span"); std.className = "tier-band-std"; std.textContent = "standard price"; btn.append(std); } // The deep link's chosen tier (the setup conversation's plan chip): // highlighted, not auto-bought — checkout stays the user's tap. if (tierLink && tierLink === t.key) { btn.classList.add("selected"); btn.setAttribute("aria-pressed", "true"); } btn.addEventListener("click", () => startUpgrade(hostOf(s.url), t.key, view.band, box)); box.append(btn); } // The ≈ attribution + currency override. Shown whenever rates exist and a // display currency is in play — including "EUR" (≈ hidden), or picking EUR // would make the override itself vanish with no way back. Euro-area locales // with no override see none of this: they're billed in their own currency. const fxNote = $("upgrade-fx"); fxNote.textContent = ""; const showFx = !!rates && !!currency; fxNote.classList.toggle("hidden", !showFx); if (showFx) { const span = document.createElement("span"); span.textContent = anyApprox ? "Billed in euros — ≈ is an estimate in " : "Billed in euros — show ≈ in "; const sel = document.createElement("select"); sel.className = "fx-currency"; sel.setAttribute("aria-label", "Currency for the approximate figures"); for (const c of [...Object.keys(rates), "EUR"].sort()) { const opt = document.createElement("option"); opt.value = c; opt.textContent = c; if (c === currency) opt.selected = true; sel.append(opt); } sel.addEventListener("change", () => { approxCurrency = sel.value; renderUpgrade(s); }); const tail = document.createElement("span"); tail.textContent = anyApprox && fxSnapshot.date ? " at the ECB rate of " + fxSnapshot.date + "." : "."; fxNote.append(span, sel, tail); } // The +15% annual-review promise, in money (never a bare percent): the // first tier at the selected band is the concrete example. const cap = $("upgrade-cap"); const first = billingTiers[0] && tierForBand(billingTiers[0], selectedBand); const example = first && first.eur ? capNote(first.eur, navigator.language) : ""; cap.textContent = "Every price is reviewed once a year" + (example ? " — " + billingTiers[0].name + ": " + example : "") + ". Rises above 15% only ever happen with your explicit yes; decreases happen by themselves."; } function closeUpgrade() { $("upgrade").classList.add("hidden"); showVault(); } // startUpgrade begins the checkout round trip for the chosen plan + band: // home mints a Stripe Checkout link (ownership enforced by the provisioner's // ledger) and we follow it in the same tab — Stripe brings you back here with // ?upgraded= when it's done, and Cancel comes straight back too. async function startUpgrade(host, tier, band, box) { for (const b of box.querySelectorAll("button")) b.disabled = true; upgradeStatus("Opening checkout…", "busy"); try { const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/checkout", { method: "POST", headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, body: JSON.stringify({ tier, band }), }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.message || body.error || `Couldn't start the upgrade (HTTP ${res.status}).`); if (!body.url) throw new Error("The checkout link didn't come back — try again."); location.href = body.url; } catch (e) { upgradeStatus(e.message || "Couldn't start the upgrade.", "error"); for (const b of box.querySelectorAll("button")) b.disabled = false; } } // ---- keep (claim) a demo space -------------------------------------------------- function claimStatus(msg, kind) { const el = $("claim-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } // consumeClaimLink honours a #claim= deep link (the demo banner's "Keep // this space") once signed in: opens the claim screen for that host. Cleared // either way, so a stale fragment can't re-fire the screen on later renders. function consumeClaimLink() { if (!claimLinkHost || !vault) return; const host = claimLinkHost; claimLinkHost = ""; history.replaceState(null, "", location.pathname + location.search); openClaim(host); } // openClaim shows the one-confirm claim screen. No pre-checks here — the // provisioner's answers (quota, not-a-demo, expired) are member-facing and // shown verbatim, so asking twice would only add a failure mode. function openClaim(host) { hideVault(); $("claim").classList.remove("hidden"); $("claim-host").textContent = host; claimStatus(""); const go = $("claim-go"); go.hidden = false; go.disabled = false; go.onclick = () => doClaim(host, go); $("claim-open").classList.add("hidden"); } function closeClaim() { $("claim").classList.add("hidden"); showVault(); } // doClaim is the real call: home relays to the provisioner (ownership // transfer, quota, expiry guard), then the space folds into this account's // encrypted list exactly like a create — sign-in always finds the way back. async function doClaim(host, btn) { btn.disabled = true; claimStatus("Claiming " + host + "…", "busy"); try { const res = await fetch("/api/spaces/claim", { method: "POST", headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, body: JSON.stringify({ host }), }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.message || body.error || `Couldn't claim this space (HTTP ${res.status}).`); const url = body.url || "https://" + host; vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); vault.obj.servers.push({ url, name: host }); vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins try { await saveVault(); } catch { // The claim stuck even if the vault save raced; the entry lands on the // next successful save. } provisioned = null; // the ledger now lists this space as this account's claimStatus("This space is yours now — the countdown is off, and it's saved to your list.", "ok"); btn.hidden = true; const open = $("claim-open"); open.href = url; open.classList.remove("hidden"); } catch (e) { claimStatus(e.message || "Couldn't claim this space.", "error"); btn.disabled = false; } } // openPortal hands off to Stripe's Billing Portal for one space — cancel, // change card, invoices. Same-tab round trip; the portal's Return link comes // back here. async function openPortal(host, btn) { btn.disabled = true; vaultStatus("Opening your billing page…", "busy"); try { const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/portal", { method: "POST", headers: { Authorization: "Bearer " + state.token }, }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.message || body.error || `Couldn't open the billing page (HTTP ${res.status}).`); if (!body.url) throw new Error("The billing link didn't come back — try again."); location.href = body.url; } catch (e) { vaultStatus(e.message || "Couldn't open the billing page.", "error"); btn.disabled = false; } } // ---- create a new space ------------------------------------------------------- // requestSpace is the real create call, fired by the fader the moment it snaps // to eleven: POST /api/spaces (link-token auth) — it returns only once the // server has confirmed the space is live and reachable, so the fader's build // phase IS the wait — then fold the new space into the vault so sign-in always // finds the way back. Throws a member-facing Error for the fader's error path. async function requestSpace() { if (!vault) throw new Error("Sign in first — your new space is saved into your account."); // The proof-of-work gate, when this home server runs one: fetch a challenge // and grind it out right here, during the fader's build phase — the CPU // burn IS the "Hold on…" moment. bits 0 = no gate, nothing to solve. const proof = await solveCreateChallenge(); // No name: a space is born nameless and Eleven asks inside it (the setup // conversation's "group" step), where the answer is a reply in a // conversation instead of a form field on the way in. Home learns the name // when the space's key is added to this account (the enrol payload's entry). const body = { ...(proof || {}) }; const res = await fetch("/api/spaces", { method: "POST", headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, body: JSON.stringify(body), }); const reply = await res.json().catch(() => ({})); if (!res.ok) { // Structured errors carry a member-facing message (quota, at-capacity). // The code rides along so the fader's error path can tell "paying would // fix this" (quota, pool pressure) from a plain failure (#1374). const err = new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`); err.code = reply.error || ""; throw err; } const url = reply.url; const host = new URL(url).host; vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); vault.obj.servers.push({ url, name: host }); vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins try { await saveVault(); } catch { // The space exists even if the vault save raced — still confirm it; the // entry lands on the next successful save. } return { host, setupLink: reply.setup_link }; } // requestPaidSpace is the paid branch behind the fader: mint the Checkout for // the chosen plan and hand the tab to Stripe. The space is only created on // return (claimPaidCreate); a localStorage marker means even a closed tab can // finish next visit. The returned promise never resolves — navigation is the // resolution. async function requestPaidSpace() { if (!vault) throw new Error("Sign in first — your new space is saved into your account."); const proof = await solveCreateChallenge(); const res = await fetch("/api/spaces/checkout-create", { method: "POST", headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, body: JSON.stringify({ ...(proof || {}), tier: planChoice.tier }), }); const reply = await res.json().catch(() => ({})); if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't start the checkout (HTTP ${res.status}).`); if (!reply.url) throw new Error("The checkout link didn't come back — try again."); if (reply.session_id) { rememberPendingCreate({ session: reply.session_id, at: Date.now() }); } location.href = reply.url; return new Promise(() => {}); } // ---- the pay-first marker + claim --------------------------------------------- // The localStorage marker for a checkout in flight: {session, at}. It // exists so a tab closed on Stripe's page still finishes the create on the // next visit here — the claim is idempotent, so over-firing is safe. const PENDING_CREATE_KEY = "lchat-pending-create"; const claimedSessions = new Set(); // auto-claim once per page life; Try again drives retries function rememberPendingCreate(m) { try { localStorage.setItem(PENDING_CREATE_KEY, JSON.stringify(m)); } catch { /* private mode — the redirect still carries the session */ } } function pendingCreateMarker() { try { return JSON.parse(localStorage.getItem(PENDING_CREATE_KEY) || "null"); } catch { return null; } } function clearPendingCreate() { try { localStorage.removeItem(PENDING_CREATE_KEY); } catch { /* nothing to clear */ } } // claimPaidCreate finishes a pay-first create: the provisioner verifies the // session with Stripe and provisions on the spot, so the create screen's // building beat is the honest wait. 402/404 mean the checkout never completed // (cancelled, expired, or another account's) — drop the marker quietly and // return to the list; the pending allocation prunes itself server-side. async function claimPaidCreate(session, region) { hideVault(); $("create-plan").classList.add("hidden"); $("create").classList.remove("hidden"); $("create-back").classList.add("hidden"); $("create-pitch").classList.add("hidden"); $("new-space-result").classList.add("hidden"); $("create-building").classList.remove("hidden"); try { const res = await fetch("/api/spaces/claim-create", { method: "POST", headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, body: JSON.stringify({ session_id: session, region: region || "" }), }); const reply = await res.json().catch(() => ({})); if (res.status === 402 || res.status === 404) { clearPendingCreate(); $("create-building").classList.add("hidden"); $("create").classList.add("hidden"); showVault(); return; } if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't finish the setup (HTTP ${res.status}).`); const url = reply.url; const host = new URL(url).host; vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== url); vault.obj.servers.push({ url, name: host }); vault.obj.removed = clearTombstone(vault.obj.removed, url); try { await saveVault(); } catch { /* the space exists; the entry lands on the next save */ } clearPendingCreate(); $("create-building").classList.add("hidden"); showNewSpaceResult(host, reply.setup_link); } catch (e) { // Retriable (the provisioner said the payment is safe): keep the marker // and offer Try again right here. $("create-building").classList.add("hidden"); const body = $("new-space-body"); body.textContent = ""; const msg = document.createElement("p"); msg.className = "create-sub"; msg.textContent = e.message || "The space couldn't be set up just now — your payment is safe."; const retry = document.createElement("button"); retry.type = "button"; retry.className = "secondary"; retry.textContent = "Try again"; retry.addEventListener("click", () => claimPaidCreate(session, region)); body.append(msg, retry); $("create-back").classList.add("hidden"); $("new-space-result").classList.remove("hidden"); } } // solveCreateChallenge grinds the home server's hashcash challenge: find a // nonce where SHA-256(challenge + ":" + nonce) clears the difficulty. Async // digests yield to the event loop, so the fader's build animation keeps // breathing while we work. null when the server runs no gate. async function solveCreateChallenge() { const res = await fetch("/api/spaces/challenge", { method: "POST", headers: { Authorization: "Bearer " + state.token }, }); const c = await res.json().catch(() => ({})); if (!res.ok || !c.bits) return null; const enc = new TextEncoder(); for (let n = 0; ; n++) { const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(c.challenge + ":" + n))); if (leadingZeroBits(digest) >= c.bits) return { challenge: c.challenge, nonce: String(n) }; } } // tryDemo: the no-account path (POST /api/demo/challenge → grind → POST // /api/demo). The hashcash burn IS the wait, same trade as creation; every // outcome lands inline in #status (never a toast) — the server's messages are // member-facing (cooldown, demos-busy) and shown verbatim. On success we go // straight into the fresh space's setup link, same-tab like the create flow. async function tryDemo() { const btn = $("try-demo"); btn.disabled = true; setStatus("Preparing your demo space — a few seconds of math keeps the bots away…", "busy"); try { const res = await fetch("/api/demo/challenge", { method: "POST" }); const c = await res.json().catch(() => ({})); if (!res.ok) throw new Error(c.message || "Demos aren't available right now."); const enc = new TextEncoder(); let nonce = null; for (let n = 0; ; n++) { const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(c.challenge + ":" + n))); if (leadingZeroBits(digest) >= c.bits) { nonce = String(n); break; } } setStatus("Building your demo space…", "busy"); const create = await fetch("/api/demo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge: c.challenge, nonce }), }); const body = await create.json().catch(() => ({})); if (!create.ok) throw new Error(body.message || "Couldn't create a demo space right now."); location.href = body.setup_link; } catch (err) { setStatus(err.message, "error"); btn.disabled = false; } } $("try-demo").addEventListener("click", tryDemo); function leadingZeroBits(bytes) { let bits = 0; for (const b of bytes) { if (b === 0) { bits += 8; continue; } for (let mask = 128; mask; mask >>= 1) { if (b & mask) return bits; bits++; } } return bits; } // A checkmark for the single-serve confirmation (static, in-code SVG). const CHECK_SVG = ''; // showNewSpaceResult fills the confirmation pane inside the create screen: a // sealed check, the new space's name, and the one-time setup link (the admin // flow — never the bare instance URL). The Done button is static in the markup. function showNewSpaceResult(host, setupLink) { const body = $("new-space-body"); body.textContent = ""; const check = document.createElement("div"); check.className = "ok-check"; check.innerHTML = CHECK_SVG; const title = document.createElement("p"); title.className = "created-name"; title.textContent = "Your space is ready"; const hostLine = document.createElement("p"); hostLine.className = "sub"; hostLine.textContent = host; body.append(check, title, hostLine); if (setupLink) { const a = document.createElement("a"); a.className = "btn-link"; // ?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#"); // Same tab, not a new one: setting up a space is where you're headed next, // and you'll come back here (to your list) once it's done — no reason to // strand this page behind a second tab. a.textContent = "Set up your space →"; const note = document.createElement("p"); note.className = "sub"; note.style.marginTop = "12px"; note.textContent = "This one-time link is only for you."; body.append(a, note); } else { // No setup link (mint failed) — deliberately not the bare instance URL, which // lands on a signed-out card rather than the admin flow. const note = document.createElement("p"); note.className = "sub"; note.textContent = "The setup link couldn't be created just now — head back and create again."; body.append(note); } $("create-back").classList.add("hidden"); // the confirmation's Done is the way out $("new-space-result").classList.remove("hidden"); } // ---- create-screen navigation ------------------------------------------------- // The fader mounts once (it builds its scale + meter into the DOM) and is // reset/started per visit; stop() parks its loop and clears the stage canvas. let fader = null; function ensureFader() { if (fader) return fader; fader = mountFader({ fader: $("fader"), meter: $("fader-meter"), shakeEl: document.querySelector("main.card"), canvas: $("stage-dust"), pitch: $("create-pitch"), building: $("create-building"), // The back link hides the moment the snap fires — the create is in flight // and can't be cancelled; error/ready each restore a way out. onEleven: () => { $("create-back").classList.add("hidden"); return planChoice && planChoice.tier ? requestPaidSpace() : requestSpace(); }, onReady: (r) => showNewSpaceResult(r.host, r.setupLink), onError: (err) => { // The two errors paying genuinely fixes — the free quota and the free // pool's budget gate — reopen the plan screen with the paid tiers // showing (#1374). Everything else stays a plain inline error. const code = (err && err.code) || ""; if ((code === "quota_exceeded" || code === "free_pool_full") && billingTiers.length) { openPlanBounce((err && err.message) || ""); return; } $("create-back").classList.remove("hidden"); spaceStatus((err && err.message) || String(err), "error"); }, }); return fader; } // The plan behind the slider — consumed by the fader's onEleven to pick the // free create or the Stripe handoff. tier "" = free, which is every create // that didn't bounce off a paid gate. let planChoice = null; let planTier = "free"; // planPaidRequired: the create bounced off a gate paying genuinely fixes // (the free quota, or the pool under budget pressure) — the plan screen // re-opens showing the paid tiers. The HAPPY path never shows them: every // space is born free, the slider creates it immediately, and "who is this // space for?" is Eleven's question inside the space (#1374). let planPaidRequired = false; // openCreate opens the create: the slider, and nothing before it. Nothing is // asked on the way in — not a name (Eleven asks inside the space), not a plan // (every space is born free, #1374) — so the fastest path from "I want a // space" to having one is a single gesture. The plan screen exists only for // the bounce (openPlanBounce). function openCreate() { clearUndo(); planChoice = null; planTier = "free"; planPaidRequired = false; planStatus(""); // The ledger read warms the price book — the paid-required error path needs // it in hand (renderPlanTiers fills in when it answers). if (spacesEnabled && provisioned === null && state.token) refreshProvisioned(); hideVault(); openCreateFader(); } function closePlan() { $("create-plan").classList.add("hidden"); showVault(); } // openPlanBounce shows the plan screen — the ONLY thing that does. The create // hit a gate paying genuinely fixes (the free quota, the pool under budget // pressure), so the paid tiers are the way through, with the gate's own // message above them. function openPlanBounce(message) { fader?.stop(); $("create").classList.add("hidden"); planPaidRequired = true; planTier = billingTiers[0].key; renderPlanTiers(); $("create-plan").classList.remove("hidden"); planStatus(message, "error"); } function planStatus(msg, kind) { const el = $("plan-status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } // renderPlanTiers paints the who-pays picker from the provisioner's price // book — but ONLY in paid-required mode (#1374): the happy path asks nothing // at all, and the plan question lives in Eleven's setup conversation. Here the // create bounced off the quota or the pool gate, so the paid tiers (no Free // card — free is exactly what bounced) are the way through, at base price // with the ≈ local figure the upgrade picker also shows. function renderPlanTiers() { const wrap = $("plan-tiers-wrap"); const showing = planPaidRequired && billingTiers.length; wrap.classList.toggle("hidden", !showing); if (!showing) return; $("plan-tiers-lead").textContent = "Pick a plan to create it now:"; // The ≈ rates ride the same one-fetch-per-page-life snapshot as the // upgrade picker; render now, again when they land. if (!fxSnapshot) { fxSnapshot = { date: "", rates: null }; apiJSON("/api/fx") .then((b) => { fxSnapshot = { date: b.date || "", rates: b.rates || null }; if (!$("create-plan").classList.contains("hidden")) renderPlanTiers(); }) .catch(() => { /* no rates — the EUR stickers stand alone */ }); } const currency = approxCurrency || regionCurrency(navigator.language); const rates = fxSnapshot && fxSnapshot.rates; const box = $("plan-tiers"); box.textContent = ""; const card = (key, title, priceLine, subLine, approx) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "tier-btn wrap" + (key === planTier ? " selected" : ""); btn.setAttribute("aria-pressed", key === planTier ? "true" : "false"); const name = document.createElement("span"); name.className = "tier-name"; name.textContent = title; const price = document.createElement("span"); price.className = "tier-price"; price.textContent = priceLine; btn.append(name, price); if (approx) { const a = document.createElement("span"); a.className = "tier-approx"; a.textContent = approx; btn.append(a); } if (subLine) { const sub = document.createElement("span"); sub.className = "tier-sub"; sub.textContent = subLine; btn.append(sub); } btn.addEventListener("click", () => { planTier = key; renderPlanTiers(); }); box.append(btn); }; for (const t of billingTiers) { const view = tierForBand(t, "base"); const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language); card(t.key, t.name, view.label + (t.business ? " excl. VAT" : ""), "", approx); } const fxNote = $("plan-fx"); fxNote.classList.toggle("hidden", !(rates && currency && currency !== "EUR")); fxNote.textContent = rates && fxSnapshot.date ? "Billed in euros — ≈ at the ECB rate of " + fxSnapshot.date + "." : ""; } // planContinue commits the bounce's chosen plan and returns to the slider — // the same commit as the free path, with a Stripe handoff behind it. function planContinue() { planChoice = { tier: planTier === "free" ? "" : planTier }; $("create-plan").classList.add("hidden"); openCreateFader(); } // openCreateFader opens step 2, the goes-to-eleven slider — the commit for // both branches; only the promise behind it differs. function openCreateFader() { $("new-space-result").classList.add("hidden"); $("create-back").classList.remove("hidden"); $("create-sub-line").textContent = planChoice && planChoice.tier ? "Slide, and we'll take you to the payment — your space is created the moment it's paid." : "Your new space will be created immediately."; spaceStatus(""); $("create").classList.remove("hidden"); const f = ensureFader(); f.reset(); f.start(); } function closeCreate() { fader?.stop(); $("create").classList.add("hidden"); showVault(); // reveals the list again, now including any newly created space } // ---- archive: list, unarchive, permanent delete -------------------------------- // archiving is the entry currently in the stern delete step of the archive. let archiving = null; // The last-admin guard: before letting someone drop the only saved copy of a // space's ONLY admin key, ask the space itself (checkSoleAdmin). While // sternSoleAdmin is set, "Delete your access" stays off; sternDestroyable // (provisioner ownership confirmed) picks which way the note points instead. let sternSoleAdmin = false; let sternDestroyable = false; // sternShell: the provisioner's ledger says this owned space was never set up // and holds only its pre-seeded admin — an unusable shell nobody can enter. // Deleting access to one strands a quota slot forever, so destroy is promoted // to the only action. Server truth (the meter sweep records claimed-ness), so // it works exactly where checkSoleAdmin can't: entries with no credentials. let sternShell = false; // sternPaid: the ledger says this space has a live subscription — destroying // it also cancels that subscription (the provisioner enforces it), and the // ceremony copy must say so. let sternPaid = false; // openArchive shows the archive screen (the archived-spaces list). function openArchive() { clearUndo(); archiving = null; hideVault(); $("archive-stern").classList.add("hidden"); $("archive-checking").classList.add("hidden"); $("archive-main").classList.remove("hidden"); $("archive").classList.remove("hidden"); archiveStatus(""); renderArchiveList(); } function closeArchive() { archiving = null; $("archive").classList.add("hidden"); showVault(); } // closeArchiveStern steps back from the delete step to the archive list. function closeArchiveStern() { archiving = null; $("archive-stern").classList.add("hidden"); $("archive-checking").classList.add("hidden"); $("archive-main").classList.remove("hidden"); archiveStatus(""); } function renderArchiveList() { const box = $("archive-list"); box.textContent = ""; const { archived } = partitionArchived(vault.obj.servers || []); if (!archived.length) { box.append(emptyLine("Nothing archived.")); return; } const ul = document.createElement("ul"); ul.className = "servers archived"; for (const s of archived) ul.append(archiveRow(s)); box.append(ul); } function archiveRow(s) { const li = document.createElement("li"); li.dataset.url = s.url; const main = document.createElement("div"); main.className = "srv-main"; const name = document.createElement("span"); // plain text — archived spaces stay quiet name.className = "name"; name.textContent = labelOf(s); if (s.name) name.title = hostOf(s.url); main.append(name); const unarch = document.createElement("button"); unarch.type = "button"; unarch.className = "pill"; unarch.textContent = "Unarchive"; unarch.addEventListener("click", () => unarchiveSpace(s)); const del = document.createElement("button"); del.type = "button"; del.className = "pill danger-ink"; del.textContent = "Delete…"; del.addEventListener("click", () => openArchiveDelete(s)); li.append(main, unarch, del); return li; } // unarchiveSpace moves the entry back to the active list. function unarchiveSpace(s) { vault.obj.servers = setArchived(vault.obj.servers, s.url, false); if (!partitionArchived(vault.obj.servers).archived.length) { closeArchive(); // archive is empty now — the space is back on the list persistVault(); return; } renderArchiveList(); persistVault(archiveStatus); } // The stern step assembles BEFORE it shows: both checks (ownership → destroy // zone, last-admin → hold) run first, so pieces never pop into a visible panel. // A fast answer shows no loader at all; past STERN_LOADER_DELAY_MS the spinner // appears, and at STERN_CHECK_CAP_MS we reveal with whatever has settled — a // straggler still applies live (rare, and correctness beats stillness). const STERN_LOADER_DELAY_MS = 150; const STERN_CHECK_CAP_MS = 2500; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // openArchiveDelete opens the stern permanent-delete step for one archived space. // "Delete your access" (drop the saved entry) is offered unless the space reports // this is its ONLY admin key (checkSoleAdmin); for a space THIS account created, // the deeper "destroy for everyone" is revealed too (ownership checked against // the provisioner ledger; the server re-enforces it on delete). async function openArchiveDelete(s) { archiving = s; sternSoleAdmin = false; sternDestroyable = false; sternShell = false; sternPaid = false; const host = hostOf(s.url); $("stern-name").textContent = labelOf(s); $("stern-host").textContent = host; $("delete-arm").value = ""; $("delete-arm").placeholder = host; $("delete-access").disabled = true; $("sole-admin-note").classList.add("hidden"); $("destroy-zone").classList.add("hidden"); $("destroy-forever").disabled = true; $("archive-main").classList.add("hidden"); $("archive-stern").classList.add("hidden"); $("archive-checking").classList.add("hidden"); archiveStatus(""); const checks = Promise.allSettled([checkOwnership(s, host), checkSoleAdmin(s)]); const fast = await Promise.race([checks.then(() => true), sleep(STERN_LOADER_DELAY_MS).then(() => false)]); if (archiving !== s) return; // backed out mid-check if (!fast) { $("archive-checking").classList.remove("hidden"); await Promise.race([checks, sleep(STERN_CHECK_CAP_MS)]); if (archiving !== s) return; $("archive-checking").classList.add("hidden"); } updateSternHold(); $("archive-stern").classList.remove("hidden"); } // checkOwnership learns whether THIS account created the space (the provisioner // ledger), which puts "destroy for everyone" on the table. Rendering happens in // updateSternHold — this only sets the flag, so it composes with the pre-reveal // assembly above and still applies live if it straggles past the cap. function checkOwnership(s, host) { if (!spacesEnabled) return Promise.resolve(); return apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } }) .then((r) => { const row = (r.spaces || []).find((sp) => sp.host === host); if (row && archiving === s) { sternDestroyable = true; sternPaid = row.entitlement === "paid"; // An owned space the ledger has SEEN (a sweep ran) but that was never // set up and never joined is an unusable shell — and when the entry // carries no credentials (never enrolled), no live check could ever // say otherwise. Promote destroy; deleting access would only strand // the quota slot. const token = s.credentials && s.credentials.token; sternShell = !token && !row.setup_complete && (row.members || 1) <= 1 && !!row.metered_at; prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule) updateSternHold(); } }) .catch(() => { /* can't confirm ownership — don't offer the destroy */ }); } // checkSoleAdmin asks the space itself — with the saved session token, straight // from the browser so this server stays blind to which spaces the user holds — // whether this key is its ONLY admin key (instances answer CORS for exactly this // origin on /api/me). Fails OPEN: an unreachable space or an older build without // the field must never make its entry undeletable, so the hold only ever engages // on a positive answer. function checkSoleAdmin(s) { const token = s.credentials && s.credentials.token; if (!token) return Promise.resolve(); let url; try { url = new URL("/api/me?token=" + encodeURIComponent(token), s.url); } catch { return Promise.resolve(); } return fetch(url, { signal: AbortSignal.timeout(8000) }) .then((r) => (r.ok ? r.json() : null)) .then((me) => { if (soleAdminHold(me) && archiving === s) { sternSoleAdmin = true; updateSternHold(); } }) .catch(() => { /* no answer — no hold */ }); } // updateSternHold renders what the checks learned: the destroy zone when this // account owns the space, and the last-admin hold — "Delete your access" goes // (and stays) off. When BOTH hold (last admin of a space this account created), // destroying is the only real action left, so it isn't offered as a footnote // link under a dead button: the whole step flips to the destroy framing — // title, copy, and the destroy button promoted to the primary slot. Rendering // is declarative from the two flags, so straggling checks re-render cleanly. function updateSternHold() { const destroyOnly = (sternSoleAdmin || sternShell) && sternDestroyable; $("stern-title").textContent = destroyOnly ? "Destroy this space?" : "Delete your access?"; $("stern-intro").classList.toggle("hidden", destroyOnly); $("delete-access").classList.toggle("hidden", destroyOnly); $("destroy-zone").classList.toggle("hidden", !sternDestroyable); $("destroy-zone").classList.toggle("primary", destroyOnly); $("destroy-note").classList.toggle("hidden", destroyOnly); const destroy = $("destroy-forever"); destroy.className = destroyOnly ? "btn-danger" : "linkish danger-link"; destroy.textContent = destroyOnly ? "Destroy the space for everyone" : "Destroy the space for everyone…"; $("destroy-paid").classList.toggle("hidden", !sternPaid); const note = $("sole-admin-note"); // The subscription line rides every destroy framing for a paid space — the // one thing a payer needs to hear before the button. const paidLine = sternPaid ? ["Its subscription is cancelled with it — no further charges. "] : []; if (!sternSoleAdmin && !sternShell) { note.classList.add("hidden"); return; } if (sternShell) { const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent }); const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." }); note.replaceChildren( host, " was never set up — it has no members and no way in, but it still ", "counts toward your space limit. Destroying it frees that slot. ", ...paidLine, noUndo, ); } else if (destroyOnly) { const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent }); const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." }); note.replaceChildren( "This is the only admin key for ", host, " — deleting just your access would ", "leave the space running with no admin, forever, and still counting toward ", "your space limit. You created it, so you can destroy the space for everyone ", "instead, which frees that slot: every message, photo, and member in it. ", ...paidLine, noUndo, ); } else { note.textContent = "This is the space's only admin key. Deleting it would leave the space " + "running with no admin, forever. Make another member an admin first, then " + "come back to delete your access."; } note.classList.remove("hidden"); $("delete-access").disabled = true; } // After a delete/destroy empties the entry, return to wherever makes sense: the // archive list if anything's left, else the main list. function afterDelete(okMsg) { archiving = null; if (partitionArchived(vault.obj.servers).archived.length) { closeArchiveStern(); renderArchiveList(); archiveStatus(okMsg, "ok"); } else { closeArchive(); vaultStatus(okMsg, "ok"); } } // deleteAccess drops the saved entry from the vault — permanently. The space // keeps running for everyone else; this only removes THIS account's saved way // back in (the strongly-worded warning makes that consequence explicit). async function deleteAccess() { if (!archiving || sternSoleAdmin) return; // the last-admin hold — see updateSternHold const s = archiving; vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url); // The tombstone makes the removal STICK: signed-in devices drop their copy // and stop re-pushing it, instead of resurrecting the entry on next sync. vault.obj.removed = addTombstone(vault.obj.removed, s.url, Date.now()); archiveStatus("Deleting…", "busy"); try { await saveVault(); afterDelete("Removed from your account."); } catch (e) { archiveStatus(e.message || "Couldn't save the change.", "error"); } } // destroyForEveryone is the owner-only deeper action: a FRESH passkey ceremony // (a link token alone can never destroy a space) then DELETE against the // provisioner, which re-checks ownership against its ledger. async function destroyForEveryone() { if (!archiving) return; const s = archiving; const host = $("stern-host").textContent; const btn = $("destroy-forever"); btn.disabled = true; archiveStatus("Confirm it’s you…", "busy"); let proof; try { ({ proof } = await runLogin(loginBegin)); noteProof(proof); // fills the confirm-step pocket (Sign-in options) } catch (e) { prefetchLogin(); archiveStatus(friendlyCeremonyError(e, "signin").message, "error"); btn.disabled = false; return; } archiveStatus("Destroying the space…", "busy"); try { const res = await fetch("/api/spaces/" + encodeURIComponent(host), { method: "DELETE", headers: { "X-Ceremony-Proof": proof }, }); const body = await res.json().catch(() => ({})); // 404 = not in the ledger (already gone / not yours) — treat "already gone" // as done; the list entry goes either way. if (!res.ok && res.status !== 404) { throw new Error(body.message || body.error || `Couldn't delete (HTTP ${res.status}).`); } vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url); vault.obj.removed = addTombstone(vault.obj.removed, s.url, Date.now()); try { await saveVault(); } catch { /* the space is gone; the entry retries next save */ } afterDelete("Space destroyed."); } catch (e) { archiveStatus(e.message || String(e), "error"); btn.disabled = false; } } // ---- wiring ------------------------------------------------------------------- const onSignInError = (err) => { // A friendly, specific line for what went wrong (ceremony-errors.js), with // the technical crumb parenthesised — it might just be the wrong passkey. // ANY failure or cancel reveals #newhere, the one place the create path // lives now: a cancelled prompt and a missing credential are deliberately // indistinguishable (NotAllowedError either way), so the explainer serves // both readings and only then offers "Create your passkey". It shows on // whichever surface ran the ceremony (main card or all-ways). setStatus(formatCeremonyError(err, "signin"), "error"); revealNewhere(); prefetchRegister(); // ready the create ceremony in case they're new setSignedOutBusy(false); prefetchLogin(); // options were spent — refresh for a retry }; const onCreatePasskeyError = (err) => { setStatus(formatCeremonyError(err, "create"), "error"); setSignedOutBusy(false); }; // Arriving from a demo banner's "Keep this space" while signed out: say why // you're looking at a sign-in page. The fragment waits; the claim screen // opens on the first vault render after sign-in (or after creating one). if (claimLinkHost && !(state.token && state.dek)) { setStatus("Sign in to keep your demo space.", ""); } // Arriving from a join page's sign-in-first card while signed out: say where // this leads, host included (the display half of the redirect guard). if (joinReturnURL() && !(state.token && state.dek)) { setStatus("Sign in, and you'll head straight back to " + hostOf(joinReturnURL()) + " to finish joining.", ""); } if (window.PublicKeyCredential) { // The passkey controls get pre-fetched ceremony options, so a click reaches // navigator.credentials.* inside the gesture (Safari activation). A browser // without WebAuthn keeps those controls off (setSignedOutBusy) — the Apple // and email doors still work. prefetchLogin(); prefetchRegister(); } composeCard(); // the #1112 card: one primary door, email beneath, the other-ways link setSignedOutBusy(false); // paints the no-WebAuthn disable onto the passkey controls $("signin").addEventListener("click", () => signIn().catch(onSignInError)); $("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError)); $("unlock-passkey").addEventListener("click", () => signIn().catch(onSignInError)); $("apple-signin").addEventListener("click", startAppleSignIn); $("google-signin").addEventListener("click", (e) => startOidcSignIn("google", e)); $("microsoft-signin").addEventListener("click", (e) => startOidcSignIn("microsoft", e)); $("github-signin").addEventListener("click", (e) => startOidcSignIn("github", e)); // The instant the Apple confirmation proceeds, this page navigates away — // the waiting face covers whatever frames remain (bfcache return releases it). window.addEventListener("pagehide", engageSiwaCover); // Coming BACK from Apple (bfcache restore) must not strand a spinning button. window.addEventListener("pageshow", (e) => { if (!e.persisted) return; releaseApplePending(); }); $("cookie-ask").addEventListener("click", () => $("cookie-dialog").showModal()); $("cookie-close").addEventListener("click", () => $("cookie-dialog").close()); $("cookie-dialog").addEventListener("click", (e) => { if (e.target === $("cookie-dialog")) $("cookie-dialog").close(); // backdrop tap }); $("email-signin").addEventListener("click", openEmailDoor); $("email-back").addEventListener("click", closeEmailDoor); $("email-form").addEventListener("submit", sendEmailCode); $("email-code-form").addEventListener("submit", verifyEmailCode); // The all-ways step: each door invokes the SAME handler as the main card's // version — the step is another surface, never another code path. $("other-ways").addEventListener("click", openOtherWays); $("otherways-back").addEventListener("click", closeOtherWays); $("otherways-apple").addEventListener("click", startAppleSignIn); $("otherways-google").addEventListener("click", (e) => startOidcSignIn("google", e)); $("otherways-microsoft").addEventListener("click", (e) => startOidcSignIn("microsoft", e)); $("otherways-github").addEventListener("click", (e) => startOidcSignIn("github", e)); $("combine-oidc-go").addEventListener("click", (e) => { if (combine && OIDC_WEB[combine.kind]) startOidcSignIn(combine.kind, e, { intent: "merge-" + combine.kind, select: true }); }); $("otherways-email").addEventListener("click", openEmailDoor); $("otherways-passkey").addEventListener("click", () => signIn().catch(onSignInError)); // The SiwA web round trip lands back here as #siwa=&state=web. // (siwa.go's trampoline — the fragment never reaches the server). The state // must match the one this tab minted before leaving for Apple; the hash is // cleared either way, so a reload can't replay the token. let siwaBooting = false; // claims the boot — the cold-open path below stands down { const m = location.hash.match(/^#siwa=([^&]*)&state=([^&]*)$/); if (m) { siwaBooting = true; // The pre-paint gate already put the waiting face up (html.siwa-wait) — // the clean card never shows. history.replaceState(null, "", location.pathname + location.search); const minted = sessionStorage.getItem("home_siwa_state") || ""; sessionStorage.removeItem("home_siwa_state"); // The methods step's round-trip markers (add-Apple's stashed proof, the // confirm-with-Apple return flag, the serialized in-progress intent) — // consumed exactly once, here, so a reload can't replay any of them. const linkMark = sessionStorage.getItem("home_siwa_link"); const methodsReturn = !!sessionStorage.getItem("home_methods_return"); const methodsIntent = sessionStorage.getItem("home_methods_intent") || ""; sessionStorage.removeItem("home_siwa_link"); sessionStorage.removeItem("home_methods_return"); sessionStorage.removeItem("home_methods_intent"); if (minted && decodeURIComponent(m[2]) === minted) { if (linkMark) finishSiwaLink(decodeURIComponent(m[1]), linkMark); else if (methodsIntent === "merge-apple") finishSiwaMergeProve(decodeURIComponent(m[1])); else finishSiwa(decodeURIComponent(m[1]), methodsReturn, methodsIntent); } else { // A state that didn't start here: straight to the signed-out card. finishHomeLoader({ immediate: true }); setStatus("That Apple sign-in didn’t start here — try again.", "error"); } } } // The OIDC web round trips (Google today) land as #oidc=&state= // (oidc.go's provider-blind trampoline). Which door was opened — and the // nonce the verifier must see round-trip — rides home_oidc_state, minted // beside the state before departure. Same one-shot marker consumption as // the #siwa= leg above. let oidcBooting = false; // claims the boot — the cold-open path below stands down if (!siwaBooting) { const m = location.hash.match(/^#oidc=([^&]*)&state=([^&]*)$/); if (m) { oidcBooting = true; history.replaceState(null, "", location.pathname + location.search); let rec = null; try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ } sessionStorage.removeItem("home_oidc_state"); const linkMark = sessionStorage.getItem("home_siwa_link"); const methodsReturn = !!sessionStorage.getItem("home_methods_return"); const methodsIntent = sessionStorage.getItem("home_methods_intent") || ""; sessionStorage.removeItem("home_siwa_link"); sessionStorage.removeItem("home_methods_return"); sessionStorage.removeItem("home_methods_intent"); if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) { if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark); else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1])); else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent); } else { finishHomeLoader({ immediate: true }); setStatus("That sign-in didn’t start here — try again.", "error"); } } } // The code-flow return leg (GitHub) lands as #oauth=&state=web. // (github.go's GET trampoline). Same one-shot consumption; the code is // single-use and worthless without the server-held secret either way. let oauthBooting = false; // claims the boot — the cold-open path below stands down if (!siwaBooting && !oidcBooting) { const m = location.hash.match(/^#oauth=([^&]*)&state=([^&]*)$/); if (m) { oauthBooting = true; history.replaceState(null, "", location.pathname + location.search); let rec = null; try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ } sessionStorage.removeItem("home_oidc_state"); const linkMark = sessionStorage.getItem("home_siwa_link"); const methodsReturn = !!sessionStorage.getItem("home_methods_return"); const methodsIntent = sessionStorage.getItem("home_methods_intent") || ""; sessionStorage.removeItem("home_siwa_link"); sessionStorage.removeItem("home_methods_return"); sessionStorage.removeItem("home_methods_intent"); if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) { if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark); else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1])); else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent); } else { finishHomeLoader({ immediate: true }); setStatus("That sign-in didn’t start here — try again.", "error"); } } } // The mail's magic link (email.go) lands here as #email=
&code=<6 // digits> — the same values the mail asks you to type, riding the fragment, // which never reaches any server. It runs the ordinary /api/email/verify, so // the code's single-use/expiry/attempt machinery is the whole story — and it // works in any browser, recovery-grade, exactly like typing the code. The // hash clears before the verify, so a reload can't rerun the (single-use // anyway) code. let emailLinkBooting = false; // claims the boot — the cold-open path below stands down if (!siwaBooting && !oidcBooting && !oauthBooting && location.hash.indexOf("#email=") === 0) { emailLinkBooting = true; // The pre-paint gate already put the waiting face up (html.siwa-wait) — // the clean card never shows. const m = location.hash.match(/^#email=([^&]*)&code=(\d{6})$/); history.replaceState(null, "", location.pathname + location.search); if (m) { finishEmailLink(decodeURIComponent(m[1]), m[2]); } else { // A mangled link (a mail client truncated it): lift to the doors. finishHomeLoader({ immediate: true }); setStatus("That sign-in link didn’t work — request a fresh code.", "error"); } } $("account-menu-btn").addEventListener("pointerdown", () => { kebabPointerOpen = true; }); $("account-menu-btn").addEventListener("click", () => { if (accountMenuOpen()) closeAccountMenu(true); else openAccountMenu(); }); // Sign out is technically instant (local state clears) — the ~700ms beat // makes it read as the event it is (Paul's rule: meaningful transitions // earn a working state; case-by-case, not universal). $("signout").addEventListener("click", () => { closeAccountMenu(false); const card = document.querySelector("main.card"); card.classList.add("waiting"); setTimeout(() => { card.classList.remove("waiting"); signOut(); }, 700); }); // Sign-in options (issue #1098): the kebab's serial step family. The list's // own controls (row kebabs, first-add buttons) are wired per render. $("how-signin").addEventListener("click", () => { closeAccountMenu(false); openMethods(); }); $("methods-back").addEventListener("click", closeMethods); $("methods-confirm-back").addEventListener("click", () => { pendingIntent = null; // backing out cancels the parked change backToMethodsList("", "", { reload: false }); }); $("methods-confirm-passkey").addEventListener("click", confirmMethodsPasskey); $("methods-confirm-apple").addEventListener("click", confirmMethodsApple); $("methods-confirm-google").addEventListener("click", (e) => confirmMethodsOidc("google", e)); $("methods-confirm-microsoft").addEventListener("click", (e) => confirmMethodsOidc("microsoft", e)); $("methods-confirm-github").addEventListener("click", (e) => confirmMethodsOidc("github", e)); $("methods-confirm-email").addEventListener("click", confirmMethodsEmail); $("methods-cemail-form").addEventListener("submit", cemailSend); $("methods-cemail-code-form").addEventListener("submit", cemailVerify); $("methods-email-back").addEventListener("click", () => backToMethodsList("", "", { reload: false })); $("methods-email-form").addEventListener("submit", methodsEmailSend); $("methods-email-code-form").addEventListener("submit", methodsEmailVerify); $("methods-passkey-back").addEventListener("click", () => backToMethodsList("", "", { reload: false })); $("methods-passkey-create").addEventListener("click", addPasskeyCeremony); // The combine step (#1098's merge): offer → prove → its spaces → the merge. $("combine-offer-go").addEventListener("click", openCombineStep); $("combine-back").addEventListener("click", () => { combine = null; // cancel leaves both accounts untouched backToMethodsList("", "", { reload: false }); }); $("combine-code-form").addEventListener("submit", combineCodeVerify); $("combine-resend").addEventListener("click", combineRequestCode); $("combine-apple-go").addEventListener("click", combineAppleProve); $("combine-unlock").addEventListener("click", combineUnlock); $("combine-skip").addEventListener("click", combineSkip); $("combine-skip-yes").addEventListener("click", combineSkipYes); $("combine-skip-no").addEventListener("click", combineVaultStage); $("open-create").addEventListener("click", openCreate); $("plan-back").addEventListener("click", closePlan); $("plan-continue").addEventListener("click", planContinue); // Back from the slider: the list normally (it's the only step there was), or // the plan screen when a bounce put one behind us — the chosen tier survives. $("create-back").addEventListener("click", () => { if (planPaidRequired) { fader?.stop(); $("create").classList.add("hidden"); $("create-plan").classList.remove("hidden"); return; } closeCreate(); }); $("create-done").addEventListener("click", closeCreate); // (Creating a space is the fader now — mounted in openCreate; see fader.js.) $("archive-back").addEventListener("click", closeArchive); $("upgrade-back").addEventListener("click", closeUpgrade); $("claim-back").addEventListener("click", closeClaim); $("archive-cancel").addEventListener("click", closeArchiveStern); $("delete-access").addEventListener("click", deleteAccess); $("destroy-forever").addEventListener("click", destroyForEveryone); // The typed address arms both delete actions — exact match only. The last-admin // hold outranks arming for "Delete your access" (destroy is the sanctioned path, // so it still arms). $("delete-arm").addEventListener("input", () => { const armed = $("delete-arm").value.trim() === $("stern-host").textContent; $("delete-access").disabled = !armed || sternSoleAdmin; $("destroy-forever").disabled = !armed; }); // Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input // lives outside #spaces, so re-rendering the lists never steals its focus. $("space-search").addEventListener("input", () => { if (vault) showVault(); }); // Learn whether this home server offers self-serve space creation, and reveal // the + on the list if it does (and we're already signed in — session rehydrate). apiJSON("/api/features") .then((f) => { spacesEnabled = !!f.spaces; // The no-account demo path — newcomers only: a browser that has ever // signed in (the #1112 marker) has outgrown the demo card. $("demo-offer").classList.toggle("hidden", !f.demo || everSignedIn()); if (vault) $("open-create").classList.toggle("hidden", !canCreateSpace()); // If the list rendered before this answer, re-render: the lazy ledger read // (Upgrade affordance) only triggers once spacesEnabled is known. if (vault && !$("vault").classList.contains("hidden")) showVault(); }) .catch(() => { /* older home server without the endpoint: leave the + hidden */ }); // The ambient smoke + motes background (bootscene.js) drifts behind every state // of the page, for everyone — started once, left running. startHomeLoader(); // The composing veil (raised pre-paint by the inline gate) lifts only now — // composeCard() has run, so the card's first visible frame is its final // composition. compose-reveal keeps the lift near-instant. document.documentElement.classList.add("compose-reveal"); document.documentElement.classList.remove("composing"); requestAnimationFrame(() => requestAnimationFrame(() => document.documentElement.classList.remove("compose-reveal"))); // Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in // sessionStorage, so the vault can reopen without a ceremony. The loading chrome // (the Eleven lockup + tagline, already up pre-paint via html.booting) hides the // Sign-in flash until the list has decrypted; finishHomeLoader lifts it — fading // the content in over the smoke — whether we land on the vault or, if the session // went stale, back on Sign-in. if (state.token && state.dek) { (async () => { try { const got = await fetchBlob(state.token); if (got) { dekKey = await importDEK(state.dek); const opened = await openBlob(got.bytes, dekKey); vault = { obj: opened.obj, keys: opened.keys, version: got.version }; setStatus(""); showVault(); } } catch { /* token or DEK went stale; the button still works */ } finally { finishHomeLoader(); } })(); } else if (!siwaBooting && !oidcBooting && !oauthBooting && !emailLinkBooting) { // The cold open (stay signed in, Option A): no tab session, but a live // persisted token in localStorage. Adopt it and land exactly like an anchor // sign-in — the sealed prompt when a blob exists, the empty list when not; // the DEK was never persisted, so the passkey ceremony stays the only way // to plaintext. Acceptance rolls the 30-day expiry; a REJECTED token // (revoked server-side) clears the persisted session and falls to the // doors — a flaky network doesn't, it just gets this boot no further. // Every path lifts the pre-paint cover. const saved = persistedSession(); if (saved) { (async () => { try { await enterAnchorSession({ token: saved.token, created: false }); persistSession(saved.token, saved.method); // accepted — another 30 days } catch (e) { if (/\b401\b/.test((e && e.message) || "")) clearPersistedSession(); } finally { finishHomeLoader(); } })(); } else if (document.documentElement.classList.contains("booting")) { // The gate raised the cover for a session that didn't survive the read // (expired between frames, or unparseable) — tidy up and lift to the doors. clearPersistedSession(); finishHomeLoader(); } }