eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// The home landing page: every sign-in method is a door into the same
2// anonymous account, and the card composes itself per browser (#1112, as
3// revised by Paul 2026-07-19): exactly ONE door — the last-used method when
4// this browser holds one, else the platform default — then a quiet "Other
5// ways to sign in" link to the all-ways step, which lists every door
6// (email included; nothing else ever sits on the main card). Apple and
7// email are find-or-create (one button resolves your account or mints it,
8// no sign-in vs sign-up split).
9// Create-a-passkey appears only after a failed ceremony (the #newhere
10// explainer — a cancelled prompt and a missing credential are deliberately
11// indistinguishable, NotAllowedError either way, so the failure moment serves
12// both readings). All crypto is client-side (crypto.js); the WebAuthn
13// ceremonies + blob I/O live in backup-core.js. Status renders inline —
14// never a toast.
15//
16// The identity-vs-key split, made visible: an Apple or email sign-in proves
17// who you are (siwa.go / email.go answer with a device link token, exactly
18// like a passkey ceremony) but can never decrypt — the vault blob only opens
19// under a passkey's PRF-derived KEK. So an anchor sign-in lands signed in
20// either empty (fresh account, no blob yet) or sealed, with a quiet
21// unlock-with-your-passkey prompt. "Sign in" is the friendly name for
22// unlocking the encrypted key vault; the server only ever holds opaque
23// ciphertext, never a usable key.
24
25import {
26 kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK,
27 sealBlob, unpackBlob, openBlob,
28} from "/static/crypto.js";
29import {
30 apiJSON, beginLogin, beginRegister, runLogin, runRegister, fetchBlob, putBlob,
31} from "/static/backup-core.js";
32import { friendlyCeremonyError, formatCeremonyError } from "/static/ceremony-errors.js";
33import {
34 partitionServers, partitionArchived, setArchived, spaceRestoreURL,
35 toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold,
36 addTombstone, clearTombstone, mergeRemoved,
37} from "/static/serverlist.js";
38import { mountFader } from "/static/fader.js";
39import { parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash } from "/static/joinreturn.js";
40import { startHomeLoader, finishHomeLoader } from "/static/bootscene.js";
41import { BANDS, bandName, bandsOffered, tierForBand, regionCurrency, approxLocal, capNote } from "/static/fx.js";
42
43// ---- session state ----------------------------------------------------------
44// The identity-vs-key split, applied to persistence (stay signed in, Option
45// A): the link TOKEN outlives the tab — it also lives in localStorage with a
46// 30-day rolling expiry, so a cold open lands signed in — but the DEK never
47// does: it stays sessionStorage-only, so closing the tab always re-seals the
48// list and a stolen disk yields membership, never plaintext. Cold opens land
49// on the sealed-vault prompt; the passkey ceremony stays the only way in.
50const state = {
51 get token() { return sessionStorage.getItem("home_token") || ""; },
52 set token(v) { sessionStorage.setItem("home_token", v); },
53 get dek() { return sessionStorage.getItem("home_dek") || ""; },
54 set dek(v) { sessionStorage.setItem("home_dek", v); },
55 get credId() { return sessionStorage.getItem("home_cred") || ""; },
56 set credId(v) { sessionStorage.setItem("home_cred", v); },
57};
58let vault = null; // { obj, keys, version } once unlocked
59let dekKey = null; // the imported CryptoKey
60
61// The persisted session, two localStorage keys: home_session (JSON {token,
62// method}) and home_session_expires (plain epoch-ms, a separate key so the
63// inline pre-paint gate can check it without JSON parsing). The expiry ROLLS:
64// any boot that accepts the session re-persists, buying another 30 days.
65const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
66
67function persistSession(token, method) {
68 try {
69 localStorage.setItem("home_session", JSON.stringify({ token, method }));
70 localStorage.setItem("home_session_expires", String(Date.now() + SESSION_TTL_MS));
71 } catch { /* private mode — the sign-in stays tab-only, as before */ }
72}
73
74function clearPersistedSession() {
75 try {
76 localStorage.removeItem("home_session");
77 localStorage.removeItem("home_session_expires");
78 } catch { /* nothing to clear */ }
79}
80
81// persistedSession reads the stored {token, method} while it's still live —
82// null when absent, expired, or unreadable. Pure read; the boot path bumps
83// the expiry only once the token has actually been accepted.
84function persistedSession() {
85 try {
86 const exp = Number(localStorage.getItem("home_session_expires") || "");
87 const s = JSON.parse(localStorage.getItem("home_session") || "null");
88 return s && s.token && exp > Date.now() ? s : null;
89 } catch { return null; }
90}
91
92// ---- tiny DOM helpers ---------------------------------------------------------
93
94const $ = (id) => document.getElementById(id);
95
96function setStatus(msg, kind) {
97 const el = $("status");
98 el.textContent = msg || "";
99 el.className = "status" + (kind ? " " + kind : "");
100}
101function vaultStatus(msg, kind) {
102 const el = $("vault-status");
103 el.textContent = msg || "";
104 el.className = "status" + (kind ? " " + kind : "");
105}
106function spaceStatus(msg, kind) {
107 const el = $("new-space-status");
108 el.textContent = msg || "";
109 el.className = "status" + (kind ? " " + kind : "");
110}
111function archiveStatus(msg, kind) {
112 const el = $("archive-status");
113 el.textContent = msg || "";
114 el.className = "status" + (kind ? " " + kind : "");
115}
116
117// hostOf is the display host for a saved url, tolerant of a non-URL value.
118function hostOf(url) { try { return new URL(url).host; } catch { return url || ""; } }
119function labelOf(s) { return s.name || hostOf(s.url); }
120
121// fmtBytes renders a storage reading for members: decimal units (matching how
122// storage is priced), one decimal for GB, whole numbers below.
123function fmtBytes(n) {
124 if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + " GB";
125 if (n >= 1e6) return Math.round(n / 1e6) + " MB";
126 return "<1 MB";
127}
128
129// Whether this home server offers self-serve space creation (set from
130// /api/features). The create-a-space controls only appear when it's both
131// enabled here and you're signed in.
132let spacesEnabled = false;
133// provisioned: host → row from GET /api/spaces — which listed spaces THIS
134// account created, plus their entitlement. billingEnabled mirrors the
135// provisioner's flag; both drive the quiet Upgrade affordance + Supporter
136// badge. null = not fetched yet (fetched lazily, once per sign-in).
137let provisioned = null;
138let billingEnabled = false;
139let billingTiers = []; // the provisioner's price book: [{key, name, label}]
140// A checkout round trip lands back here with ?upgraded=<host>; consumed (once)
141// by the first showVault after return.
142let upgradedHost = new URLSearchParams(location.search).get("upgraded") || "";
143if (upgradedHost) history.replaceState(null, "", location.pathname + location.hash);
144// A pay-first create lands back with ?paidcreate=<session>&pcregion=<region>
145// (Stripe fills the session id into the template). Consumed by the first
146// vault render after sign-in, which claims the space into existence; a
147// localStorage marker backs it up so a closed tab can still finish later.
148let paidCreateSession = new URLSearchParams(location.search).get("paidcreate") || "";
149let paidCreateRegion = new URLSearchParams(location.search).get("pcregion") || "";
150if (paidCreateSession) history.replaceState(null, "", location.pathname + location.hash);
151// A space's own menu deep-links here as #upgrade=<host>; consumed once the
152// provisioner ledger is in hand (before sign-in it just waits in the fragment).
153// Optional &band=<band> and &tier=<key> ride along (the space menu doesn't
154// send them, but shared links and the setup conversation's plan chip do).
155let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)((?:&(?:band=[a-z]+|tier=[a-z0-9_-]+))*)$/) || [])[1] || "").toLowerCase();
156// The conversation's chosen tier (#1374): highlights that card in the picker —
157// a nudge, never a commitment; every card stays one tap from checkout.
158let tierLink = ((location.hash.match(/[#&]tier=([a-z0-9_-]+)/) || [])[1] || "").toLowerCase();
159// A shared link pre-selects an honor band (docs/pricing-bands.md § The
160// picker — "click a link, get the lower price, instantly"): #band=reduced,
161// ?band=reduced, or riding an #upgrade fragment. The server honours whatever
162// band arrives; this only pre-selects the picker. Unknown names are ignored.
163let bandLink = (() => {
164 const m = location.hash.match(/[#&]band=([a-z]+)/) || location.search.match(/[?&]band=([a-z]+)/);
165 const b = m ? m[1] : "";
166 return BANDS.some((x) => x.key === b) ? b : "";
167})();
168// A demo space's countdown banner deep-links here as #claim=<host>; waits in
169// the fragment through sign-in — a demo needs no account, so its claimant
170// often has to create their sign-in first, and the claim resumes after.
171let claimLinkHost = ((location.hash.match(/^#claim=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase();
172// A join page sends its visitor here to sign in FIRST (#joinret=<url> — the
173// signup-first join flow). The return URL parks in sessionStorage so it
174// survives the anchor doors' round trips (Apple/Google leave this page and
175// come back via their trampolines, dropping the hash), and the first vault
176// render after sign-in sends the person straight back to finish joining
177// instead of showing the space list. Guarded both ends: joinreturn.js pins
178// the destination to an https /join/ path.
179{
180 const jr = parseJoinReturn(location.hash);
181 if (jr && validJoinReturn(jr)) {
182 try { sessionStorage.setItem("home_join_return", stashJoinReturn(jr, Date.now())); } catch { /* no storage — plain sign-in */ }
183 history.replaceState(null, "", location.pathname + location.search);
184 }
185}
186function joinReturnURL() {
187 try { return readJoinReturnStash(sessionStorage.getItem("home_join_return") || "", Date.now()); } catch { return ""; }
188}
189// consumeJoinReturn: the redirect back. True = navigating; callers stop rendering.
190function consumeJoinReturn() {
191 const url = joinReturnURL();
192 if (!url) return false;
193 try { sessionStorage.removeItem("home_join_return"); } catch {}
194 location.replace(url);
195 return true;
196}
197
198// Ceremony options are pre-fetched so the button click reaches
199// navigator.credentials.* synchronously — Safari drops the user activation
200// across an intervening network await and rejects with NotAllowedError.
201let loginBegin = null;
202function prefetchLogin() {
203 beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
204}
205let registerBegin = null;
206function prefetchRegister() {
207 if (registerBegin) return;
208 beginRegister().then((v) => { registerBegin = v; }).catch(() => { registerBegin = null; });
209}
210
211// ---- blob save ----------------------------------------------------------------
212
213// saveVault seals the current vault and PUTs it, retrying once on a version
214// race (re-fetch, re-apply our object over the merge base, re-seal). The list
215// is tiny, so last-writer-wins per entry is fine at this stage.
216async function saveVault() {
217 for (let attempt = 0; attempt < 2; attempt++) {
218 const sealed = await sealBlob(vault.obj, dekKey, vault.keys);
219 const v = await putBlob(state.token, sealed, vault.version);
220 if (v !== null) {
221 vault.version = v;
222 return;
223 }
224 const cur = await fetchBlob(state.token);
225 if (!cur) throw new Error("backup disappeared");
226 const opened = await openBlob(cur.bytes, dekKey);
227 const merged = new Map(opened.obj.servers.map((s) => [s.url, s]));
228 for (const s of vault.obj.servers) merged.set(s.url, s);
229 // Tombstones union newest-per-url, except where OUR list still holds the
230 // entry — this page's list is the user's current intent, so a removal that
231 // raced in from elsewhere loses to it (and an entry we just deleted here
232 // keeps its tombstone and wins over the other writer's copy).
233 const removed = mergeRemoved(opened.obj.removed, vault.obj.removed)
234 .filter((t) => !vault.obj.servers.some((s) => s.url === t.url));
235 // Method labels merge the same way: the other writer's base, ours
236 // overlaid (last-writer-wins per key; a raced deletion may resurrect a
237 // label — they're a nicety, that's fine at this stage).
238 const methods = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) };
239 const obj = {
240 ...opened.obj,
241 servers: [...merged.values()].filter((s) => !removed.some((t) => t.url === s.url)),
242 };
243 if (removed.length) obj.removed = removed; else delete obj.removed;
244 if (Object.keys(methods).length) obj.methods = methods; else delete obj.methods;
245 vault = { obj, keys: opened.keys, version: cur.version };
246 }
247 throw new Error("could not save after retry");
248}
249
250// ---- sign in ------------------------------------------------------------------
251
252// All the doors disable together while any of them is mid-flight — a second
253// WebAuthn request would only cancel the sheet that's already up, and a second
254// door mid-code-round-trip only confuses. The passkey controls additionally
255// stay off in a browser with no WebAuthn (the Apple/email doors still work).
256function setSignedOutBusy(busy) {
257 for (const id of ["apple-signin", "google-signin", "microsoft-signin", "github-signin",
258 "email-signin", "email-send", "email-verify", "other-ways", "otherways-apple",
259 "otherways-google", "otherways-microsoft", "otherways-github", "otherways-email"]) $(id).disabled = busy;
260 for (const id of ["signin", "create-passkey", "unlock-passkey", "otherways-passkey"]) {
261 $(id).disabled = busy || !window.PublicKeyCredential;
262 }
263}
264
265// markSignedIn records which method last signed in on this browser — this is
266// deliberately the #1112 last-used marker ("passkey" | "email" | "apple").
267// localStorage on purpose: it outlives the session-only tab state, and
268// sign-out leaves it — a returning person is still returning. Its absence is
269// also what lets the newcomers-only demo card show, so marking retires the
270// card on the spot.
271function markSignedIn(method) {
272 try { localStorage.setItem("home_last_signin", method); } catch { /* no storage — the card just stays */ }
273 $("demo-offer").classList.add("hidden");
274}
275
276// everSignedIn gates the demo card: an absent marker (or no storage at all,
277// where nothing could ever have been recorded) reads as a newcomer.
278function everSignedIn() {
279 try { return !!localStorage.getItem("home_last_signin"); } catch { return false; }
280}
281
282// ---- the card algorithm (#1112) ------------------------------------------------
283
284// Which doors are LIVE on this build. Google and Microsoft ship lit;
285// GitHub lights per-instance below (its flow needs a server-held client
286// secret, so the page's client id is the signal). A future door slots in
287// the same way: a flag here + its button in the markup — no restructuring.
288const DOORS = {
289 apple: true,
290 email: true,
291 passkey: true,
292 google: true, // Android/ChromeOS's platform door (client id below)
293 microsoft: true, // Windows's platform door (client id below)
294 github: false, // set below — lit by the page's per-instance client id
295};
296const GITHUB_CLIENT_ID =
297 (document.querySelector('meta[name="github-client-id"]') || {}).content || "";
298DOORS.github = !!GITHUB_CLIENT_ID && GITHUB_CLIENT_ID !== "__GITHUB_CLIENT_ID__";
299
300// platformDefault picks the door for a browser with no last-used marker: the
301// OS vendor's account, since that's the one a person most likely holds.
302// userAgentData.platform where it exists (Chromium); navigator.platform + UA
303// as the fallback (Safari/Firefox — iPadOS reports "MacIntel", which still
304// reads apple). A platform whose door is still dark falls through to
305// email-first until it ships.
306function platformDefault() {
307 const hints = ((navigator.userAgentData && navigator.userAgentData.platform) ||
308 navigator.platform || "") + " " + navigator.userAgent;
309 const door =
310 /Mac|iPhone|iPad|iPod|iOS/i.test(hints) ? "apple" :
311 /Android|CrOS|Chrome OS/i.test(hints) ? "google" :
312 /Windows|Win32|Win64/i.test(hints) ? "microsoft" : // never bare /Win/ — "Darwin" contains it
313 "email";
314 return DOORS[door] ? door : "email";
315}
316
317// primaryDoor: last-used wins, else the platform default. The marker only
318// counts when it names a LIVE door — a value from a newer build (say
319// "google" before this build lights it) must not compose a door that isn't
320// here.
321function primaryDoor() {
322 let last = "";
323 try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* no storage — platform default */ }
324 return DOORS[last] ? last : platformDefault();
325}
326
327// composeCard arranges the main card: exactly ONE door — last-used when
328// known, else the platform default — then the quiet "Other ways to sign in"
329// link. Never a second door on the card (Paul 2026-07-19, revising #1112's
330// email-always-beneath rule): every demoted door, email included, lives in
331// the all-ways step. Runs at boot and again on sign-out (the marker
332// survives it and may have moved).
333function composeCard() {
334 const primary = primaryDoor();
335 const apple = $("apple-signin"), email = $("email-signin"), passkey = $("signin");
336 const google = $("google-signin"), microsoft = $("microsoft-signin");
337 const github = $("github-signin");
338 github.classList.toggle("hidden", primary !== "github");
339 apple.classList.toggle("hidden", primary !== "apple");
340 google.classList.toggle("hidden", primary !== "google");
341 microsoft.classList.toggle("hidden", primary !== "microsoft");
342 // Promotions restyle in place: email sheds .secondary when it leads, and
343 // the passkey door trades its grey link for a proper primary button.
344 // Wholesale className is safe — these two never carry other classes.
345 email.className = primary === "email" ? "" : "secondary hidden";
346 passkey.className = primary === "passkey" ? "" : "passkey-link hidden";
347 // DOM order is focus order: the one door, then the link. append MOVES the
348 // existing (already-wired) nodes; hidden doors stay parked, unfocusable.
349 $("main-doors").append({ apple, email, passkey, google, microsoft, github }[primary], $("other-ways"));
350}
351
352async function signIn() {
353 setSignedOutBusy(true);
354 setStatus("Confirming it’s you…", "busy");
355 const { token, credId, prf, proof } = await runLogin(loginBegin);
356 state.token = token;
357 state.credId = credId;
358 noteProof(proof); // fills the confirm-step pocket (Sign-in options)
359 markSignedIn("passkey");
360 persistSession(token, "passkey");
361 const got = await fetchBlob(token);
362 if (!got) {
363 // Zero spaces is a SUCCESS state, not a dead end: land in the normal
364 // signed-in view, empty, with the create affordance. A PRF result lets
365 // this passkey seal future saves (same shape as createPasskey — and like
366 // there, nothing is written until the first space enrols).
367 if (prf) {
368 const kek = await kekFromPRF(prf);
369 dekKey = await generateDEK();
370 state.dek = await exportDEK(dekKey);
371 vault = { obj: { v: 1, servers: [] }, keys: { [credId]: await wrapDEK(dekKey, kek) }, version: "0" };
372 } else {
373 dekKey = null;
374 vault = { obj: { v: 1, servers: [] }, keys: {}, version: "0" };
375 }
376 setStatus("");
377 showVault();
378 return;
379 }
380 if (!prf) {
381 setStatus("Signed in, but this browser didn't return a PRF result, so your spaces can't be unlocked here.", "error");
382 setSignedOutBusy(false);
383 return;
384 }
385 const kek = await kekFromPRF(prf);
386 const { keys } = unpackBlob(got.bytes);
387 const wrapped = keys[credId];
388 if (!wrapped) {
389 setStatus("That sign-in can't unlock these spaces.", "error");
390 setSignedOutBusy(false);
391 return;
392 }
393 dekKey = await unwrapDEK(wrapped, kek);
394 state.dek = await exportDEK(dekKey);
395 const opened = await openBlob(got.bytes, dekKey);
396 vault = { obj: opened.obj, keys: opened.keys, version: got.version };
397 setStatus(""); // "Signed in" is obvious — the list appearing says it
398 showVault();
399}
400
401// ---- create a sign-in (the up-front "Create your sign-in" action) --------------
402
403// createPasskey registers a brand-new passkey and starts an empty vault under
404// it, then lands in the (empty) spaces list like any sign-in. Reached from the
405// up-front create button — quieter than Sign in, but never hidden behind a
406// failed ceremony (see the header note).
407async function createPasskey() {
408 setSignedOutBusy(true);
409 setStatus("Creating your sign-in…", "busy");
410 let reg;
411 try {
412 reg = await runRegister(registerBegin);
413 } catch (e) {
414 registerBegin = null;
415 prefetchRegister(); // options were spent — refresh for a retry
416 if (e.message === "no-prf") {
417 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");
418 setSignedOutBusy(false);
419 return;
420 }
421 throw e;
422 }
423 state.token = reg.token;
424 state.credId = reg.credId;
425 noteProof(reg.proof); // register/finish mints one too (finishCeremony)
426 markSignedIn("passkey"); // register/finish is a sign-in too (#1112)
427 persistSession(reg.token, "passkey");
428
429 const kek = await kekFromPRF(reg.prf);
430 dekKey = await generateDEK();
431 state.dek = await exportDEK(dekKey);
432 // Deliberately NOT persisted: no blob is written until the first space is
433 // enrolled (enroll.js starts one under this passkey). Blob-existence is the
434 // server's creation-eligibility gate — "has at least one space" — and an
435 // empty vault saved here would quietly turn every fresh passkey into an
436 // eligible account (the + stays hidden, but the API wouldn't 403).
437 vault = {
438 obj: { v: 1, servers: [] },
439 keys: { [reg.credId]: await wrapDEK(dekKey, kek) },
440 version: "0",
441 };
442 setSignedOutBusy(false);
443 setStatus("Your sign-in is ready — next time it's one tap.", "ok");
444 showVault();
445}
446
447// ---- the identity anchors: Apple + email ---------------------------------------
448
449// startAppleSignIn hands the whole page to Apple's authorize endpoint — a
450// full-page navigate, no Apple JS SDK (the CSP pins scripts to self). The
451// state is minted here, "web."-prefixed so the /siwa/return trampoline
452// bounces back to this page instead of the native apps' custom scheme, and
453// parked in sessionStorage for the return check.
454// The Apple round trip's waiting face: the ambient smoke (already running
455// behind the page) comes to full and a ring holds the lockup's spot — no
456// brand yet; this is the wait, not the arrival. A pure opacity crossfade
457// over the boot cover's classes: nothing heavy starts at tap time. The same
458// face goes up pre-paint on the return leg (the #siwa= gate in index.html).
459function engageSiwaCover() {
460 document.documentElement.classList.add("booting", "siwa-wait");
461}
462function releaseSiwaCover() {
463 document.documentElement.classList.remove("booting", "siwa-wait");
464}
465
466// The Apple tap's staged wait: stage-clear → (timer) the waiting face. A
467// cancelled sheet never navigates, so any later input — or a bfcache
468// return — releases the whole arrangement, cover included.
469let applePendingTimer = 0;
470let applePendingBtn = null; // whichever Apple door was pressed (main card or all-ways)
471function releaseApplePending() {
472 clearTimeout(applePendingTimer);
473 releaseSiwaCover();
474 document.querySelector("main.card").classList.remove("apple-pending");
475 if (applePendingBtn) {
476 applePendingBtn.disabled = false;
477 applePendingBtn.classList.remove("loading");
478 applePendingBtn = null;
479 }
480 window.removeEventListener("pointerdown", releaseApplePending, true);
481 window.removeEventListener("keydown", releaseApplePending, true);
482 // A cancelled trip must not leave the methods step's round-trip markers
483 // behind — a stale one would misroute a LATER Apple sign-in into link mode.
484 try {
485 sessionStorage.removeItem("home_siwa_link");
486 sessionStorage.removeItem("home_methods_return");
487 sessionStorage.removeItem("home_methods_intent");
488 } catch { /* nothing stored */ }
489}
490
491function startAppleSignIn(e, opts = {}) {
492 // Tap: the button spins and the REST of the form fades away — the wait
493 // for Apple's sheet is just the button at work. By 1.6s the sheet is up,
494 // so the smoke cover takes over then; post-fingerprint there is nothing
495 // left to flash. pagehide backstops fast confirms; pointer/key input or a
496 // bfcache return releases a cancelled arrangement. The main card's door
497 // and the all-ways step's are the same handler — the stage-clear CSS keys
498 // off .loading, not an id, so it clears whichever surface is up.
499 const appleBtn = e.currentTarget;
500 applePendingBtn = appleBtn;
501 appleBtn.disabled = true;
502 appleBtn.classList.add("loading");
503 document.querySelector("main.card").classList.add("apple-pending");
504 applePendingTimer = setTimeout(engageSiwaCover, 1600);
505 window.addEventListener("pointerdown", releaseApplePending, true);
506 window.addEventListener("keydown", releaseApplePending, true);
507 const rand = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
508 const st = "web." + rand;
509 // The methods step's round-trip markers ride sessionStorage beside the
510 // state: linkProof turns the #siwa= return into "add Apple to MY account"
511 // (link:true + the stashed proof), methodsReturn routes a confirm-with-
512 // Apple sign-in straight back to the step, and intent is the serialized
513 // in-progress change (intentToken) so the return RESUMES it instead of
514 // landing on the bare list. Set fresh or CLEARED per trip, so a stale
515 // marker from an abandoned attempt can never misroute later.
516 try {
517 if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof));
518 else sessionStorage.removeItem("home_siwa_link");
519 if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1");
520 else sessionStorage.removeItem("home_methods_return");
521 if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent);
522 else sessionStorage.removeItem("home_methods_intent");
523 } catch { /* no storage — the return leg lands as a plain sign-in */ }
524 sessionStorage.setItem("home_siwa_state", st);
525 location.href = "https://appleid.apple.com/auth/authorize" +
526 "?client_id=com.elevenmessenger.signin" +
527 "&redirect_uri=" + encodeURIComponent(location.origin + "/siwa/return") +
528 "&response_type=code%20id_token" +
529 "&response_mode=form_post" +
530 "&state=" + encodeURIComponent(st);
531}
532
533// finishSiwa completes the round trip: the id_token from the return fragment
534// goes to the one verifier (/api/siwa/verify), which answers exactly like a
535// passkey ceremony — then the anchor-session landing sorts the outcomes. The
536// pre-paint waiting face (smoke + ring, html.siwa-wait) is up the whole time;
537// verify success earns the arrival: ring gives way to the Eleven lockup,
538// a beat to land, then the cover lifts as the spaces screen slides up.
539async function finishSiwa(idToken, backToMethods = false, intentStr = "") {
540 setStatus("Confirming it’s you…", "busy");
541 try {
542 const res = await apiJSON("/api/siwa/verify", {
543 method: "POST",
544 headers: { "Content-Type": "application/json" },
545 body: JSON.stringify({ identity_token: idToken }),
546 });
547 noteProof(res.proof); // fills the confirm-step pocket (Sign-in options)
548 markSignedIn("apple");
549 persistSession(res.token, "apple");
550 await enterAnchorSession(res); // renders the landing beneath the cover
551 } catch {
552 if (backToMethods && state.token) {
553 // A failed confirm-with-Apple: the tab's session is untouched — land
554 // back on the list with the refusal (the intent was already consumed).
555 await openMethods();
556 methodsStatus("That Apple sign-in couldn’t be confirmed — try again.", "error");
557 finishHomeLoader({ immediate: true });
558 return;
559 }
560 // No brand moment for a sign-in that didn't happen: lift straight to
561 // the signed-out card and say what went wrong.
562 setStatus("That Apple sign-in couldn’t be confirmed — try again.", "error");
563 finishHomeLoader({ immediate: true });
564 return;
565 }
566 if (backToMethods) {
567 // A confirm-with-Apple round trip (the methods step sent us): no arrival
568 // pageantry — straight back to Sign-in options, proof in the pocket. A
569 // persisted intent resumes the interrupted change — the add step opens
570 // (or the removal re-arms) instead of landing on the bare list.
571 await openMethods();
572 const intent = parseIntentToken(intentStr);
573 if (intent && freshProof()) runIntent(intent, freshProof());
574 finishHomeLoader({ immediate: true });
575 return;
576 }
577 anchorArrival();
578}
579
580// anchorArrival is phase two for a covered anchor sign-in (Apple's return leg,
581// the email magic link): the lockup fades in over the ring's spot (~400ms),
582// dwells (~450ms), then the cover lifts while the spaces screen slides up
583// into place beneath it. Reduced motion: no dwell, no slide — plain fades
584// (brand-in and arrive are inert under the reduce query).
585function anchorArrival() {
586 const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
587 document.documentElement.classList.remove("siwa-wait"); // ring → lockup
588 $("app-loading").classList.add("brand-in");
589 setTimeout(() => {
590 // .arrive stays on while the vault is visible — swapping the computed
591 // animation-name back to .screen's live would RESTART the entry fade
592 // (the after-flash); it comes off in hideVault, under display:none.
593 $("vault").classList.add("arrive");
594 finishHomeLoader({ immediate: true });
595 }, reduced ? 0 : 850); // 400ms brand fade + 450ms dwell
596}
597
598// finishSiwaLink completes an ADD-APPLE round trip (the #siwa= return with a
599// link marker): the ceremony proof stashed before leaving for Apple rode
600// sessionStorage — same tab — and must still be inside its 5-minute window on
601// return. The link verify never signs anyone in; this tab's own session
602// (which survived the navigation) is resumed instead, and the outcome lands
603// inline on the Sign-in options step.
604async function finishSiwaLink(idToken, markRaw) {
605 let mark = null;
606 try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ }
607 let outcome, conflict = false;
608 if (!mark || !mark.proof || !(mark.expires > Date.now())) {
609 // The proof's 5-minute window lapsed during the trip — re-prompt.
610 dropProof(); // the pocket mirrors the same lapsed proof
611 outcome = { msg: "That took a little too long — confirm it's you again, then tap Add Apple.", kind: "error" };
612 } else {
613 try {
614 const res = await fetch("/api/siwa/verify", {
615 method: "POST",
616 headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof },
617 body: JSON.stringify({ identity_token: idToken, link: true }),
618 });
619 if (res.ok) {
620 // The proof just verified — good for the window's remainder, so a
621 // chained second add skips the confirm step.
622 noteProof(mark.proof, mark.expires);
623 outcome = { msg: "Apple is now a way into your account.", kind: "ok" };
624 } else {
625 // The server's refusals verbatim: a lapsed proof (401 — the line
626 // asks to confirm again), or the 409 "that Apple ID already signs in
627 // somewhere else" — the COMBINE doorway (#1098): the offer keys off
628 // the status, never off message text.
629 const msg = ((await res.text().catch(() => "")) || "").trim();
630 conflict = res.status === 409;
631 // A 409 still verified the proof (only the identity clashed); any
632 // other refusal is almost always the lapsed proof — re-confirm next.
633 if (conflict) noteProof(mark.proof, mark.expires); else dropProof();
634 outcome = { msg: msg || "Couldn't add Apple — try again.", kind: "error" };
635 }
636 } catch {
637 outcome = { msg: "Couldn't add Apple — try again.", kind: "error" };
638 }
639 }
640 if (await resumeAfterLinkReturn()) {
641 await openMethods();
642 methodsStatus(outcome.msg, outcome.kind);
643 if (conflict) {
644 combine = { kind: "apple" };
645 showCombineOffer("methods-status");
646 }
647 } else {
648 // No session survived the round trip (storage cleared mid-flight) — the
649 // doors, with the outcome where the visitor will read it.
650 setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error");
651 }
652 finishHomeLoader({ immediate: true });
653}
654
655// ---- the OIDC web doors (Google today; Microsoft slots in) ----------------------
656
657// The Apple journey, generalized per oidc.go's provider table: the page
658// mints the state AND a nonce (Google refuses a bare id_token request; the
659// verifier checks it round-trips), departs for the provider's authorize
660// endpoint, and lands back through the provider-blind /oidc/return
661// trampoline as #oidc=<id_token>&state=web.<r>. Which door was opened rides
662// sessionStorage (home_oidc_state), never the fragment. The pending/cover
663// choreography and the methods round-trip markers are the Apple machinery,
664// shared verbatim. Like Apple's client id, a provider's is a public
665// identifier and lives here in code.
666const GOOGLE_CLIENT_ID =
667 "981276018948-b4cr3ctqi6cr9e8rs2fmqouip1hmd9iq.apps.googleusercontent.com";
668const MICROSOFT_CLIENT_ID = "25874cf3-213c-4124-9641-65de8a513787";
669const OIDC_WEB = {
670 github: {
671 display: "GitHub",
672 verify: "/api/oidc/github/verify",
673 // Code flow, not OIDC: no nonce; the server exchanges the code with
674 // the secret it holds (github.go) and answers the same contract.
675 payload: (cred) => ({ code: cred }),
676 authorize: (st) => "https://github.com/login/oauth/authorize" +
677 "?client_id=" + encodeURIComponent(GITHUB_CLIENT_ID) +
678 "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") +
679 "&state=" + encodeURIComponent(st),
680 },
681 microsoft: {
682 display: "Microsoft",
683 verify: "/api/oidc/microsoft/verify",
684 // The "common" endpoint admits any tenant plus personal accounts —
685 // oidc.go's checkIssuer handles the per-tenant iss.
686 authorize: (st, nonce, sel) => "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
687 "?client_id=" + encodeURIComponent(MICROSOFT_CLIENT_ID) +
688 "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") +
689 "&response_type=id_token" +
690 "&response_mode=form_post" +
691 "&scope=openid" +
692 "&nonce=" + encodeURIComponent(nonce) +
693 "&state=" + encodeURIComponent(st) +
694 (sel ? "&prompt=select_account" : ""),
695 },
696 google: {
697 display: "Google",
698 verify: "/api/oidc/google/verify",
699 authorize: (st, nonce, sel) => "https://accounts.google.com/o/oauth2/v2/auth" +
700 "?client_id=" + encodeURIComponent(GOOGLE_CLIENT_ID) +
701 "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") +
702 "&response_type=id_token" +
703 "&response_mode=form_post" +
704 "&scope=openid" +
705 "&nonce=" + encodeURIComponent(nonce) +
706 "&state=" + encodeURIComponent(st) +
707 (sel ? "&prompt=select_account" : ""),
708 },
709};
710
711function startOidcSignIn(provider, e, opts = {}) {
712 // The same staged wait as the Apple tap (see startAppleSignIn): button
713 // spins, stage clears, the smoke cover takes over by the time the
714 // provider's page is up; any input releases a cancelled arrangement.
715 const btn = e.currentTarget;
716 applePendingBtn = btn;
717 btn.disabled = true;
718 btn.classList.add("loading");
719 document.querySelector("main.card").classList.add("apple-pending");
720 applePendingTimer = setTimeout(engageSiwaCover, 1600);
721 window.addEventListener("pointerdown", releaseApplePending, true);
722 window.addEventListener("keydown", releaseApplePending, true);
723 const hex = () => Array.from(crypto.getRandomValues(new Uint8Array(16)),
724 (b) => b.toString(16).padStart(2, "0")).join("");
725 const st = "web." + hex();
726 const nonce = hex();
727 try {
728 if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof));
729 else sessionStorage.removeItem("home_siwa_link");
730 if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1");
731 else sessionStorage.removeItem("home_methods_return");
732 if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent);
733 else sessionStorage.removeItem("home_methods_intent");
734 } catch { /* no storage — the return leg lands as a plain sign-in */ }
735 sessionStorage.setItem("home_oidc_state", JSON.stringify({ state: st, provider, nonce }));
736 location.href = OIDC_WEB[provider].authorize(st, nonce, !!opts.select);
737}
738
739// finishOidc — finishSiwa's twin against /api/oidc/{provider}/verify: the
740// same one-verifier answer (token + proof), the same anchor-session landing
741// under the waiting face.
742function oidcPayload(p, cred, rec) {
743 return p.payload ? p.payload(cred) : { identity_token: cred, nonce: rec.nonce };
744}
745
746async function finishOidc(rec, cred, backToMethods = false, intentStr = "") {
747 const p = OIDC_WEB[rec.provider];
748 setStatus("Confirming it’s you…", "busy");
749 try {
750 const res = await apiJSON(p.verify, {
751 method: "POST",
752 headers: { "Content-Type": "application/json" },
753 body: JSON.stringify(oidcPayload(p, cred, rec)),
754 });
755 noteProof(res.proof);
756 markSignedIn(rec.provider);
757 persistSession(res.token, rec.provider);
758 await enterAnchorSession(res);
759 } catch {
760 if (backToMethods && state.token) {
761 await openMethods();
762 methodsStatus("That " + p.display + " sign-in couldn’t be confirmed — try again.", "error");
763 finishHomeLoader({ immediate: true });
764 return;
765 }
766 setStatus("That " + p.display + " sign-in couldn’t be confirmed — try again.", "error");
767 finishHomeLoader({ immediate: true });
768 return;
769 }
770 if (backToMethods) {
771 await openMethods();
772 const intent = parseIntentToken(intentStr);
773 if (intent && freshProof()) runIntent(intent, freshProof());
774 finishHomeLoader({ immediate: true });
775 return;
776 }
777 // The landing rendered beneath the cover — this is what LIFTS it (the
778 // ring gives way to the lockup, then the spaces screen). Its omission
779 // stranded the first live Google sign-in on the waiting face.
780 anchorArrival();
781}
782
783// finishOidcLink — finishSiwaLink's twin: the ADD round trip (link:true +
784// the stashed proof). The 409 grows the same combine offer as Apple's leg —
785// the prove step sends the person back through the clashing provider's own
786// door (combine-oidc-go → the merge-<provider> intent).
787async function finishOidcLink(rec, cred, markRaw) {
788 const p = OIDC_WEB[rec.provider];
789 let mark = null;
790 try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ }
791 let outcome, conflict = false;
792 if (!mark || !mark.proof || !(mark.expires > Date.now())) {
793 dropProof(); // the pocket mirrors the same lapsed proof
794 outcome = { msg: "That took a little too long — confirm it's you again, then tap Add " + p.display + ".", kind: "error" };
795 } else {
796 try {
797 const res = await fetch(p.verify, {
798 method: "POST",
799 headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof },
800 body: JSON.stringify({ ...oidcPayload(p, cred, rec), link: true }),
801 });
802 if (res.ok) {
803 // The proof just verified — good for the window's remainder, so a
804 // chained second add skips the confirm step.
805 noteProof(mark.proof, mark.expires);
806 outcome = { msg: p.display + " is now a way into your account.", kind: "ok" };
807 } else {
808 const msg = ((await res.text().catch(() => "")) || "").trim();
809 conflict = res.status === 409;
810 // A 409 still verified the proof (only the identity clashed); any
811 // other refusal is almost always the lapsed proof — re-confirm next.
812 if (conflict) noteProof(mark.proof, mark.expires); else dropProof();
813 outcome = { msg: msg || "Couldn't add " + p.display + " — try again.", kind: "error" };
814 }
815 } catch {
816 outcome = { msg: "Couldn't add " + p.display + " — try again.", kind: "error" };
817 }
818 }
819 if (await resumeAfterLinkReturn()) {
820 await openMethods();
821 methodsStatus(outcome.msg, outcome.kind);
822 if (conflict) {
823 combine = { kind: rec.provider };
824 showCombineOffer("methods-status");
825 }
826 } else {
827 setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error");
828 }
829 finishHomeLoader({ immediate: true });
830}
831
832// finishSiwaMergeProve is the merge-apple intent's return leg: the combine
833// step sent the person to Apple to prove the OTHER account is theirs. The
834// id_token verifies in SIGN-IN mode, but the answer is deliberately NOT
835// adopted — the absorbed account's token+proof are held in memory for the
836// merge only, and this tab's own (survivor) session resumes as-is.
837async function finishSiwaMergeProve(idToken) {
838 let creds = null;
839 try {
840 const res = await apiJSON("/api/siwa/verify", {
841 method: "POST",
842 headers: { "Content-Type": "application/json" },
843 body: JSON.stringify({ identity_token: idToken }),
844 });
845 creds = { token: res.token, proof: res.proof, expires: Date.now() + PROOF_TTL_MS };
846 } catch { /* falls through to the refusal below */ }
847 if (!(await resumeAfterLinkReturn())) {
848 setStatus("The combine couldn't resume — sign in and try again.", "error");
849 finishHomeLoader({ immediate: true });
850 return;
851 }
852 await openMethods();
853 if (!creds) {
854 methodsStatus("That Apple sign-in couldn’t be confirmed — try combining again.", "error");
855 finishHomeLoader({ immediate: true });
856 return;
857 }
858 combine = { kind: "apple", absorbedToken: creds.token, absorbedProof: creds.proof, absorbedExpires: creds.expires };
859 openCombineStep(); // proven — lands straight on the spaces stage
860 finishHomeLoader({ immediate: true });
861}
862
863// finishOidcMergeProve — finishSiwaMergeProve's provider twin: the combine
864// step sent the person to Google/Microsoft/GitHub to prove the OTHER
865// account; the answer is held for the merge, never adopted.
866async function finishOidcMergeProve(rec, cred) {
867 const p = OIDC_WEB[rec.provider];
868 let creds = null;
869 try {
870 const res = await apiJSON(p.verify, {
871 method: "POST",
872 headers: { "Content-Type": "application/json" },
873 body: JSON.stringify(oidcPayload(p, cred, rec)),
874 });
875 creds = { token: res.token, proof: res.proof, expires: Date.now() + PROOF_TTL_MS };
876 } catch { /* falls through to the refusal below */ }
877 if (!(await resumeAfterLinkReturn())) {
878 setStatus("The combine couldn't resume — sign in and try again.", "error");
879 finishHomeLoader({ immediate: true });
880 return;
881 }
882 await openMethods();
883 if (!creds) {
884 methodsStatus("That " + p.display + " sign-in couldn't be confirmed — try combining again.", "error");
885 finishHomeLoader({ immediate: true });
886 return;
887 }
888 combine = { kind: rec.provider, absorbedToken: creds.token, absorbedProof: creds.proof, absorbedExpires: creds.expires };
889 openCombineStep(); // proven — lands straight on the spaces stage
890 finishHomeLoader({ immediate: true });
891}
892
893// resumeAfterLinkReturn rebuilds the signed-in view after an add-Apple round
894// trip: the tab's own session survived the navigation (sessionStorage), or
895// the stay-signed-in token did (localStorage). True once a signed-in screen
896// is up again; false means every session evaporated mid-trip.
897async function resumeAfterLinkReturn() {
898 try {
899 if (state.token && state.dek) {
900 const got = await fetchBlob(state.token);
901 if (got) {
902 dekKey = await importDEK(state.dek);
903 const opened = await openBlob(got.bytes, dekKey);
904 vault = { obj: opened.obj, keys: opened.keys, version: got.version };
905 showVault();
906 return true;
907 }
908 }
909 const saved = state.token ? { token: state.token } : persistedSession();
910 if (saved) {
911 await enterAnchorSession({ token: saved.token, created: false });
912 return true;
913 }
914 } catch { /* fall through to the doors */ }
915 return false;
916}
917
918// The email door (email.go): type your address, get a 6-digit code, type it —
919// find-or-create like Apple. The address goes only into the two request
920// bodies; nothing here stores it. It's a card step of its own — the doors
921// swap out for it, same screen-swap pattern as the signed-in screens. It can
922// be entered from the main card or the all-ways step; Back returns to
923// whichever surface opened it.
924let emailDoorFrom = "signed-out";
925function openEmailDoor() {
926 emailDoorFrom = $("otherways").classList.contains("hidden") ? "signed-out" : "otherways";
927 $("signed-out").classList.add("hidden");
928 $("otherways").classList.add("hidden");
929 $("reassure").classList.add("hidden");
930 $("email-door").classList.remove("hidden");
931 setStatus("");
932 $("email-addr").focus();
933}
934
935// resetEmailDoor folds the door back to its address step (shared with
936// signOut, so the next visitor never sees a half-finished code round trip).
937function resetEmailDoor() {
938 $("email-door").classList.add("hidden");
939 $("email-code-form").classList.add("hidden");
940 $("email-form").classList.remove("hidden");
941 $("email-note").classList.remove("hidden");
942 $("email-addr").value = "";
943 $("email-code").value = "";
944}
945
946function closeEmailDoor() {
947 resetEmailDoor();
948 setStatus("");
949 if (emailDoorFrom === "otherways") {
950 // Back to the all-ways step; #signed-out stays hidden, so the reassure/
951 // cookie/greeter visibility rules stay as the step left them.
952 $("otherways").classList.remove("hidden");
953 return;
954 }
955 $("signed-out").classList.remove("hidden");
956 $("reassure").classList.remove("hidden");
957}
958
959// ---- the all-ways step (#1112) --------------------------------------------------
960
961// openOtherWays swaps the card to the every-door step — the same screen-swap
962// pattern as the email door (hiding #signed-out is what keeps the greeter/
963// cookie/reassure rules honest). Focus lands on the first door; Back hands
964// it back to the link.
965function openOtherWays() {
966 $("signed-out").classList.add("hidden");
967 $("reassure").classList.add("hidden");
968 // The #newhere explainer follows the visible sign-in surface (revealNewhere).
969 if (!$("newhere").classList.contains("hidden")) $("otherways").append($("newhere"));
970 $("otherways").classList.remove("hidden");
971 setStatus("");
972 $("otherways-apple").focus();
973}
974
975function closeOtherWays() {
976 resetOtherWays();
977 setStatus("");
978 $("signed-out").classList.remove("hidden");
979 $("reassure").classList.remove("hidden");
980 $("other-ways").focus();
981}
982
983// resetOtherWays parks the all-ways step and re-homes the #newhere explainer
984// to its markup home — shared with the arrival paths (showVault and friends),
985// so signing in from the step never strands either piece.
986function resetOtherWays() {
987 $("otherways").classList.add("hidden");
988 $("signed-out").append($("newhere"));
989}
990
991// revealNewhere shows the after-failure explainer on whichever sign-in
992// surface is up — the passkey door sits on the main card only when promoted,
993// so most ceremonies (and their failures) happen inside the all-ways step.
994function revealNewhere() {
995 ($("otherways").classList.contains("hidden") ? $("signed-out") : $("otherways"))
996 .append($("newhere"));
997 $("newhere").classList.remove("hidden");
998}
999
1000async function sendEmailCode(e) {
1001 e.preventDefault();
1002 const email = $("email-addr").value.trim();
1003 setSignedOutBusy(true);
1004 setStatus("Sending your code…", "busy");
1005 // The button spins for at least a beat even when the server is instant —
1006 // a mail send that appears to take zero time reads as a glitch, and the
1007 // sudden step-swap was jarring (Paul). The floor, not a fake: the request
1008 // runs concurrently.
1009 const sendBtn = $("email-send");
1010 sendBtn.classList.add("loading");
1011 const spinFloor = new Promise((r) => setTimeout(r, 900));
1012 try {
1013 await Promise.all([apiJSON("/api/email/request", {
1014 method: "POST",
1015 headers: { "Content-Type": "application/json" },
1016 body: JSON.stringify({ email }),
1017 }), spinFloor]);
1018 // The server answers the same 200 known-or-not (no enumeration), so this
1019 // line is honest for everyone.
1020 // The code step takes the address step's PLACE — not a row below it.
1021 setStatus("Check your email for a 6-digit code.", "");
1022 $("email-form").classList.add("hidden");
1023 $("email-note").classList.add("hidden");
1024 $("email-code-form").classList.remove("hidden");
1025 setSignedOutBusy(false);
1026 sendBtn.classList.remove("loading");
1027 $("email-code").focus();
1028 } catch (err) {
1029 setStatus(err.message || "Couldn’t send the code — try again.", "error");
1030 setSignedOutBusy(false);
1031 sendBtn.classList.remove("loading");
1032 }
1033}
1034
1035async function verifyEmailCode(e) {
1036 e.preventDefault();
1037 const email = $("email-addr").value.trim();
1038 const code = $("email-code").value.trim();
1039 setSignedOutBusy(true);
1040 setStatus("Checking your code…", "busy");
1041 // Same trade as Send code, a touch longer (Paul: "it's too fast!"): the
1042 // button spins for a floor the verify races, so the signed-in landing
1043 // never swaps in before the moment has registered.
1044 const verifyBtn = $("email-verify");
1045 verifyBtn.classList.add("loading");
1046 const spinFloor = new Promise((r) => setTimeout(r, 1200));
1047 try {
1048 const [res] = await Promise.all([apiJSON("/api/email/verify", {
1049 method: "POST",
1050 headers: { "Content-Type": "application/json" },
1051 body: JSON.stringify({ email, code }),
1052 }), spinFloor]);
1053 noteProof(res.proof); // fills the confirm-step pocket (Sign-in options)
1054 markSignedIn("email");
1055 persistSession(res.token, "email");
1056 await enterAnchorSession(res);
1057 setSignedOutBusy(false);
1058 verifyBtn.classList.remove("loading");
1059 } catch (err) {
1060 // The server's refusals are member-facing ("that code didn't work").
1061 setStatus(err.message || "That code didn’t work — try again.", "error");
1062 setSignedOutBusy(false);
1063 verifyBtn.classList.remove("loading");
1064 }
1065}
1066
1067// finishEmailLink completes the mail's magic link (the #email= boot fragment):
1068// the address+code from the fragment — the very values the mail asks you to
1069// type — go to the ordinary /api/email/verify, so the link shares the code's
1070// single-use/expiry/attempt machinery and adds no auth surface of its own.
1071// The pre-paint waiting face is up the whole time; success lands exactly like
1072// the typed-code path, then earns the same arrival as Apple's return.
1073async function finishEmailLink(email, code) {
1074 setStatus("Confirming it’s you…", "busy");
1075 try {
1076 const res = await apiJSON("/api/email/verify", {
1077 method: "POST",
1078 headers: { "Content-Type": "application/json" },
1079 body: JSON.stringify({ email, code }),
1080 });
1081 noteProof(res.proof); // fills the confirm-step pocket (Sign-in options)
1082 markSignedIn("email");
1083 persistSession(res.token, "email");
1084 await enterAnchorSession(res); // renders the landing beneath the cover
1085 } catch {
1086 // An expired or already-redeemed code: no brand moment — lift straight to
1087 // the doors and say what to do next.
1088 setStatus("That sign-in link has expired — request a fresh code.", "error");
1089 finishHomeLoader({ immediate: true });
1090 return;
1091 }
1092 anchorArrival();
1093}
1094
1095// enterAnchorSession lands an email/Apple sign-in. The token is held exactly
1096// like the passkey path holds its own (sessionStorage, this tab only); the
1097// ceremony proof is not kept, matching the passkey path, which only ever uses
1098// one transiently. An anchor authenticates but can never decrypt (no PRF), so:
1099// fresh account or no blob yet → the normal signed-in view, empty; an
1100// existing sealed blob → the quiet unlock-with-your-passkey prompt.
1101async function enterAnchorSession(res) {
1102 state.token = res.token;
1103 dekKey = null;
1104 const got = res.created ? null : await fetchBlob(res.token);
1105 if (got) {
1106 vault = null;
1107 setStatus("");
1108 showSealedVault();
1109 return;
1110 }
1111 vault = { obj: { v: 1, servers: [] }, keys: {}, version: "0" };
1112 setStatus("");
1113 showVault();
1114}
1115
1116// showSealedVault is the signed-in view for an account whose list exists but
1117// can't open here: the token works, the blob is real, and the DEK that opens
1118// it lives behind a passkey's PRF. The prompt runs the ordinary sign-in
1119// ceremony, which decrypts and takes over the view.
1120function showSealedVault() {
1121 $("signed-out").classList.add("hidden");
1122 $("reassure").classList.add("hidden");
1123 resetEmailDoor(); // arriving from the email door hides it
1124 resetOtherWays(); // …and from the all-ways step likewise
1125 $("vault").classList.remove("hidden");
1126 $("open-create").classList.add("hidden");
1127 $("space-search").classList.add("hidden");
1128 $("spaces").textContent = "";
1129 $("vault-sealed").classList.remove("hidden");
1130}
1131
1132// ---- sign out -----------------------------------------------------------------
1133
1134// signOut forgets this browser's session — the tab's token and DEK, and the
1135// persisted stay-signed-in token — and returns to the signed-out landing. It
1136// doesn't revoke the passkey or touch the backup: signing back in is one tap.
1137// (Revoking a device is a separate, ceremony-gated action.) The #1112
1138// last-used marker deliberately survives: a returning person is still
1139// returning.
1140function signOut() {
1141 sessionStorage.removeItem("home_token");
1142 sessionStorage.removeItem("home_dek");
1143 sessionStorage.removeItem("home_cred");
1144 clearPersistedSession();
1145 vault = null;
1146 dekKey = null;
1147 provisioned = null; // the ledger read is per-sign-in
1148 hideVault();
1149 $("create").classList.add("hidden");
1150 $("create-plan").classList.add("hidden");
1151 $("upgrade").classList.add("hidden");
1152 $("claim").classList.add("hidden");
1153 $("archive").classList.add("hidden");
1154 for (const s of METHOD_SCREENS) $(s).classList.add("hidden");
1155 resetMethodsSteps();
1156 pendingIntent = null;
1157 combine = null;
1158 anchors = null;
1159 armedRemove = "";
1160 dropProof(); // proofs are per-person, never per-browser
1161 archiving = null;
1162 clearUndo();
1163 $("spaces").textContent = "";
1164 $("vault-sealed").classList.add("hidden");
1165 $("signed-out").classList.remove("hidden");
1166 $("newhere").classList.add("hidden"); // fresh signed-out state, no stale explainer
1167 resetEmailDoor(); // back to the doors, address step ready for next time
1168 resetOtherWays();
1169 composeCard(); // the last-used marker survives sign-out and may have moved the primary
1170 $("reassure").classList.remove("hidden");
1171 setSignedOutBusy(false);
1172 setStatus("Signed out.", "");
1173 prefetchLogin(); // ready a fresh ceremony for the next sign-in
1174}
1175
1176// ---- the account menu (issue #1098's single control) ---------------------------
1177
1178// One kebab in the vault header collects everything about the account itself:
1179// "Sign-in options" (the methods step family below) and Sign out;
1180// account-merge joins it later (#1098's next slice).
1181// Menu semantics by hand: focus lands on the first item on open and returns
1182// to the kebab on close; Escape and click-outside both close.
1183function accountMenuOpen() {
1184 return !$("account-menu").classList.contains("hidden");
1185}
1186
1187// A pointerdown on the kebab marks the NEXT open as pointer-born: focus
1188// still moves into the menu (menu semantics), but the ring stays keyboard-
1189// only — Chrome's focus-visible heuristic treats script focus as keyboard
1190// and over-eagerly ringed mouse opens (Paul's catch).
1191let kebabPointerOpen = false;
1192function openAccountMenu() {
1193 $("account-menu").classList.toggle("pointer-open", kebabPointerOpen);
1194 kebabPointerOpen = false;
1195 $("account-menu").classList.remove("hidden");
1196 $("account-menu-btn").setAttribute("aria-expanded", "true");
1197 $("account-menu").querySelector('[role="menuitem"]').focus();
1198 addEventListener("pointerdown", accountMenuOutside, true);
1199 addEventListener("keydown", accountMenuKey, true);
1200}
1201
1202// close(refocus): refocus only for keyboard paths (Escape, the kebab toggle) —
1203// a click elsewhere shouldn't yank focus back to the header.
1204function closeAccountMenu(refocus) {
1205 if (!accountMenuOpen()) return;
1206 $("account-menu").classList.add("hidden");
1207 $("account-menu-btn").setAttribute("aria-expanded", "false");
1208 removeEventListener("pointerdown", accountMenuOutside, true);
1209 removeEventListener("keydown", accountMenuKey, true);
1210 if (refocus) $("account-menu-btn").focus();
1211}
1212
1213function accountMenuOutside(e) {
1214 if (!e.target.closest || !e.target.closest(".kebab-wrap")) closeAccountMenu(false);
1215}
1216
1217function accountMenuKey(e) {
1218 if (e.key === "Escape") { e.preventDefault(); closeAccountMenu(true); }
1219}
1220
1221// ---- sign-in options (issue #1098) ----------------------------------------------
1222
1223// The ceremony-proof pocket. Removing or adding a sign-in method needs a
1224// FRESH proof (the server holds them to a 5-minute window — stateless.go's
1225// ceremonyWindow); every ceremony that mints one drops it here, and
1226// freshProof answers only while a client-side mirror of that window still
1227// has margin, so a call never leaves with a proof that will be refused on
1228// arrival. The pocket mirrors into sessionStorage — same tab, like the
1229// round-trip markers — because adding Apple or an OIDC provider IS a
1230// full-page navigation: without the mirror, every return leg came back to
1231// an empty pocket and the next change re-demanded a ceremony inside the
1232// same 5-minute window. Sign-out drops it (proofs are per-person).
1233const PROOF_TTL_MS = 5 * 60 * 1000;
1234const PROOF_MARGIN_MS = 10 * 1000;
1235let ceremonyProof = null; // { proof, expires } | null
1236function noteProof(proof, expires) {
1237 if (!proof) return;
1238 ceremonyProof = { proof, expires: expires || Date.now() + PROOF_TTL_MS };
1239 try { sessionStorage.setItem("home_proof", JSON.stringify(ceremonyProof)); } catch { /* memory still holds it */ }
1240}
1241function dropProof() {
1242 ceremonyProof = null;
1243 try { sessionStorage.removeItem("home_proof"); } catch { /* nothing stored */ }
1244}
1245function freshProof() {
1246 if (!ceremonyProof) {
1247 try { ceremonyProof = JSON.parse(sessionStorage.getItem("home_proof") || "null"); } catch { /* mangled — an empty pocket */ }
1248 }
1249 return ceremonyProof && ceremonyProof.expires - PROOF_MARGIN_MS > Date.now()
1250 ? ceremonyProof.proof : null;
1251}
1252
1253// The kind vocabulary, generalized: every sign-in method the product knows,
1254// in listing order. A linked kind's ADD lives in each of its rows' kebab (a
1255// plain "Add another" — the row already names the kind); a kind with ZERO
1256// linked entries gets a big first-add button below the list instead. Future
1257// doors (google/microsoft) slot in here — same shape, gated by their DOORS
1258// flag — with no restructuring.
1259const METHOD_KINDS = [
1260 { kind: "passkey", name: "Passkey", first: "Add a passkey",
1261 enabled: () => !!window.PublicKeyCredential, start: (e) => startAddPasskey(e) },
1262 { kind: "siwa", name: "Apple", first: "Add Apple",
1263 enabled: () => DOORS.apple, start: (e) => startAddApple(e) },
1264 { kind: "github", name: "GitHub", first: "Add GitHub",
1265 enabled: () => DOORS.github, start: (e) => startAddOidc("github", e) },
1266 { kind: "microsoft", name: "Microsoft", first: "Add Microsoft",
1267 enabled: () => DOORS.microsoft, start: (e) => startAddOidc("microsoft", e) },
1268 { kind: "google", name: "Google", first: "Add Google",
1269 enabled: () => DOORS.google, start: (e) => startAddGoogle(e) },
1270 { kind: "email", name: "Email", first: "Add email",
1271 enabled: () => DOORS.email, start: (e) => startAddEmail(e) },
1272];
1273
1274// The step family is SERIAL card screens — one at a time, each with a back:
1275// the list, the confirm beat, add-email, add-passkey. (Add-Apple's "step" is
1276// the Apple journey itself.) showMethodsScreen swaps within the family;
1277// callers manage focus.
1278const METHOD_SCREENS = ["methods", "methods-confirm", "methods-email", "methods-passkey", "methods-combine"];
1279function showMethodsScreen(id) {
1280 closeRowMenu(false);
1281 for (const s of METHOD_SCREENS) $(s).classList.toggle("hidden", s !== id);
1282}
1283
1284function methodsStatus(msg, kind) {
1285 const el = $("methods-status");
1286 el.textContent = msg || "";
1287 el.className = "status" + (kind ? " " + kind : "");
1288}
1289function confirmStatus(msg, kind) {
1290 const el = $("methods-confirm-status");
1291 el.textContent = msg || "";
1292 el.className = "status" + (kind ? " " + kind : "");
1293}
1294function emailStepStatus(msg, kind) {
1295 const el = $("methods-email-status");
1296 el.textContent = msg || "";
1297 el.className = "status" + (kind ? " " + kind : "");
1298}
1299function passkeyStepStatus(msg, kind) {
1300 const el = $("methods-passkey-status");
1301 el.textContent = msg || "";
1302 el.className = "status" + (kind ? " " + kind : "");
1303}
1304
1305// anchors is the last-fetched listing (GET /api/anchors) — renderMethods'
1306// input, and what the confirm step consults to pick a ceremony this account
1307// can actually run. armedRemove holds the row currently in its inline
1308// are-you-sure state, so re-renders keep it armed.
1309let anchors = null;
1310let armedRemove = "";
1311// pendingIntent is the change the confirm step interrupted, AS DATA — the one
1312// continuation system behind every confirm path. In-page ceremonies resume it
1313// from memory (payloads intact); the confirm-with-Apple round trip carries
1314// its intentToken through sessionStorage and the return leg resumes the same
1315// way. Shapes: { kind: "add-email" | "add-passkey" | "add-apple" }, { kind:
1316// "verify-email", email, code } (memory-only payload), { kind: "remove",
1317// anchorKind, id, armed }.
1318let pendingIntent = null;
1319
1320// intentToken / parseIntentToken serialize a pending intent for the Apple
1321// confirmation's full-page round trip (sessionStorage, set-or-cleared per
1322// trip like the other markers). Only the change's IDENTITY rides — never a
1323// payload: a typed address/code stays in memory, so "verify-email" degrades
1324// to reopening the add-email step (the same mailed code can be retyped
1325// there), and a removal's passed are-you-sure is deliberately dropped (a
1326// reloaded page re-arms rather than auto-deleting). Anchor ids are
1327// base64url/hex, so ":" can't ambiguate the remove form.
1328function intentToken(intent) {
1329 if (!intent) return "";
1330 if (intent.kind === "remove") return "remove:" + intent.anchorKind + ":" + intent.id;
1331 if (intent.kind === "verify-email") return "add-email";
1332 if (intent.kind === "combine-finish") return ""; // the combine context is memory-only
1333 return intent.kind; // add-email | add-passkey | add-apple
1334}
1335function parseIntentToken(s) {
1336 if (["add-email", "add-passkey", "add-apple", "add-google", "add-microsoft", "add-github"].includes(s)) return { kind: s };
1337 const m = /^remove:([^:]+):(.+)$/.exec(s || "");
1338 return m ? { kind: "remove", anchorKind: m[1], id: m[2] } : null;
1339}
1340
1341// runIntent performs a parked change once a fresh proof is in hand. Callers
1342// guarantee the pocket is fresh; the intent decides its own navigation.
1343function runIntent(intent, proof) {
1344 switch (intent.kind) {
1345 case "add-email": openMethodsEmailStep(); return;
1346 case "add-passkey": openMethodsPasskeyStep(); return;
1347 case "add-apple": startAddApple(null); return; // pocket is fresh — straight to Apple in link mode
1348 case "add-google": startAddOidc("google", null); return;
1349 case "add-microsoft": startAddOidc("microsoft", null); return;
1350 case "add-github": startAddOidc("github", null); return;
1351 case "verify-email":
1352 // In-memory only (never serialized): retry the exact typed code — it
1353 // survived, link mode gates on the proof BEFORE redeeming (email.go).
1354 backToMethodsList("", "", { reload: false });
1355 finishEmailAdd(intent.email, intent.code, proof);
1356 return;
1357 case "combine-finish":
1358 // In-memory only: the survivor's proof lapsed mid-combine; finish now.
1359 combineFinish(intent.movedSpaces || 0);
1360 return;
1361 case "remove": {
1362 const a = (anchors || []).find((x) => x.kind === intent.anchorKind && x.id === intent.id);
1363 backToMethodsList("", "", { reload: !a }); // row gone meanwhile → just the fresh list
1364 if (!a) return;
1365 if (intent.armed) {
1366 // The are-you-sure was passed right before an in-page confirm —
1367 // complete the removal.
1368 doRemoveAnchor(a, proof);
1369 } else {
1370 // The Apple round trip reloaded the page: the destructive tap stays
1371 // explicit — re-arm THAT row's are-you-sure instead of auto-deleting.
1372 armedRemove = intent.anchorKind + "/" + intent.id;
1373 renderMethods();
1374 focusMethodRow(armedRemove, "button.danger-ink");
1375 }
1376 return;
1377 }
1378 }
1379}
1380
1381async function openMethods() {
1382 hideVault();
1383 resetMethodsSteps();
1384 armedRemove = "";
1385 pendingIntent = null;
1386 methodsStatus("");
1387 showMethodsScreen("methods");
1388 $("methods-title").focus(); // announce the step; rows load in beneath
1389 await loadAnchors();
1390}
1391
1392function closeMethods() {
1393 resetMethodsSteps();
1394 pendingIntent = null;
1395 combine = null; // abandoning the flow touches neither account
1396 for (const s of METHOD_SCREENS) $(s).classList.add("hidden");
1397 // Back to whichever signed-in view this session actually has — anchor-born
1398 // sessions may still be sealed (no vault object to render).
1399 if (vault) showVault(); else showSealedVault();
1400}
1401
1402// backToMethodsList returns from a sub-step (or finishes a flow) to the
1403// list, with the outcome line where the person lands.
1404function backToMethodsList(msg, kind, { reload = true } = {}) {
1405 showMethodsScreen("methods");
1406 $("methods-title").focus();
1407 methodsStatus(msg || "", kind);
1408 if (reload) loadAnchors();
1409}
1410
1411// resetMethodsSteps parks every sub-step — shared by open/close/sign-out so
1412// no half-finished round trip greets the next visit.
1413function resetMethodsSteps() {
1414 // The confirm step.
1415 $("methods-confirm-passkey").classList.add("hidden");
1416 $("methods-confirm-apple").classList.add("hidden");
1417 $("methods-confirm-google").classList.add("hidden");
1418 $("methods-confirm-microsoft").classList.add("hidden");
1419 $("methods-confirm-github").classList.add("hidden");
1420 $("methods-confirm-email").classList.add("hidden");
1421 $("methods-cemail").classList.add("hidden");
1422 $("methods-cemail-form").classList.remove("hidden");
1423 $("methods-cemail-code-form").classList.add("hidden");
1424 $("methods-cemail-addr").value = "";
1425 $("methods-cemail-code").value = "";
1426 confirmStatus("");
1427 // The add-email step.
1428 $("methods-email-form").classList.remove("hidden");
1429 $("methods-email-code-form").classList.add("hidden");
1430 $("methods-email-addr").value = "";
1431 $("methods-email-code").value = "";
1432 emailStepStatus("");
1433 // The add-passkey step.
1434 passkeyAddBegin = null;
1435 passkeyStepStatus("");
1436 // The combine step's stages + the 409 offer (the combine CONTEXT itself
1437 // survives — only closing the family or finishing clears it).
1438 $("combine-offer").classList.add("hidden");
1439 $("combine-prove").classList.add("hidden");
1440 $("combine-code").classList.add("hidden");
1441 $("combine-apple-go").classList.add("hidden");
1442 $("combine-oidc-go").classList.add("hidden");
1443 $("combine-code-input").value = "";
1444 $("combine-vault").classList.add("hidden");
1445 $("combine-unlock").classList.add("hidden");
1446 $("combine-skip").classList.add("hidden");
1447 $("combine-skip-confirm").classList.add("hidden");
1448 combineStatus("");
1449}
1450
1451async function loadAnchors() {
1452 try {
1453 const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } });
1454 anchors = r.anchors || [];
1455 renderMethods();
1456 } catch (e) {
1457 methodsStatus(e.message || "Couldn't load your sign-in options — try again.", "error");
1458 }
1459}
1460
1461// methodAdded is the row's quiet second line — "added 12 Jul", year only
1462// when it isn't this year's.
1463function methodAdded(ts) {
1464 if (!ts) return "";
1465 const d = new Date(ts * 1000);
1466 const opts = { day: "numeric", month: "short" };
1467 if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
1468 return "added " + d.toLocaleDateString(undefined, opts);
1469}
1470
1471// ---- method labels --------------------------------------------------------------
1472
1473// A method row's human label ("personal", the email address) is E2EE state,
1474// never server metadata: vault.obj.methods = { "<kind>/<id>": { label } },
1475// keyed the way the anchors listing names rows, riding the sealed blob like
1476// space names do. Sealed sessions (no DEK) neither show nor offer labels.
1477let editingLabel = "";
1478
1479function canLabel() { return !!(vault && dekKey); }
1480
1481function methodLabel(key) {
1482 const m = vault && vault.obj && vault.obj.methods;
1483 return (m && m[key] && m[key].label) || "";
1484}
1485
1486async function setMethodLabel(key, label) {
1487 if (!canLabel()) return;
1488 const m = { ...(vault.obj.methods || {}) };
1489 if (label) m[key] = { label }; else delete m[key];
1490 if (Object.keys(m).length) vault.obj.methods = m; else delete vault.obj.methods;
1491 await saveVault();
1492}
1493
1494// labelNewEmail stores a just-verified address as its anchor's label. The
1495// client can't compute the anchor id (that's the server's peppered blind
1496// index), so diff the listing: the email id that wasn't there before the
1497// add is the one just created.
1498async function labelNewEmail(email) {
1499 if (!canLabel()) return;
1500 const before = new Set((anchors || []).filter((x) => x.kind === "email").map((x) => x.id));
1501 try {
1502 const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } });
1503 const fresh = (r.anchors || []).filter((x) => x.kind === "email" && !before.has(x.id));
1504 if (fresh.length === 1) { await setMethodLabel("email/" + fresh[0].id, email); renderMethods(); }
1505 } catch { /* the label is a nicety — the anchor itself is already in */ }
1506}
1507
1508function renderMethods() {
1509 closeRowMenu(false);
1510 const box = $("methods-list");
1511 box.textContent = "";
1512 if (anchors && anchors.length) {
1513 const ul = document.createElement("ul");
1514 ul.className = "methods";
1515 for (const a of anchors) ul.append(methodRow(a));
1516 box.append(ul);
1517 } else if (anchors) {
1518 // Can't normally happen (a signed-in account always has a way in) — a
1519 // racing removal from another device can strand a beat, though.
1520 box.append(emptyLine("No sign-in methods to show."));
1521 }
1522 renderMethodsAdd();
1523}
1524
1525// renderMethodsAdd builds the big first-add buttons: one per LIVE kind with
1526// zero linked entries (a linked kind's add lives in its rows' kebab).
1527function renderMethodsAdd() {
1528 const box = $("methods-add");
1529 box.textContent = "";
1530 for (const k of METHOD_KINDS) {
1531 if (!k.enabled()) continue;
1532 if ((anchors || []).some((a) => a.kind === k.kind)) continue;
1533 const btn = document.createElement("button");
1534 btn.type = "button";
1535 btn.className = "secondary";
1536 btn.textContent = k.first;
1537 const glyph = kindGlyph(k.kind);
1538 if (glyph) btn.prepend(glyph);
1539 btn.addEventListener("click", (e) => k.start(e));
1540 box.append(btn);
1541 }
1542}
1543
1544// The same three dots as the account kebab (static, in-code SVG).
1545const KEBAB_SVG =
1546 '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">' +
1547 '<circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>';
1548
1549function methodRow(a) {
1550 const li = document.createElement("li");
1551 const key = a.kind + "/" + a.id;
1552 li.dataset.key = key;
1553 const def = METHOD_KINDS.find((k) => k.kind === a.kind);
1554 const label = def ? def.name : a.kind;
1555 const tag = methodLabel(key); // "" when unlabelled, or the vault is sealed
1556 const added = methodAdded(a.created_at);
1557 // A labelled row leads with the label — the distinguisher earns the bold
1558 // ("1Password Eleven key" over "Passkey · added 3 Jul"); unlabelled rows
1559 // keep the kind on top. The aria name reads in the same order.
1560 const longName = (tag ? tag + ", " + label : label) + (added ? ", " + added : "");
1561
1562 const main = document.createElement("div");
1563 main.className = "method-main";
1564 const name = document.createElement("span");
1565 name.className = "method-name";
1566 name.textContent = tag || label;
1567 main.append(name);
1568 if (editingLabel === key) {
1569 main.append(labelEditor(key, tag, longName));
1570 } else {
1571 const subText = (tag ? [label, added] : [added]).filter(Boolean).join(" · ");
1572 if (subText) {
1573 const sub = document.createElement("span");
1574 sub.className = "method-added";
1575 sub.textContent = subText;
1576 main.append(sub);
1577 }
1578 }
1579 li.append(main);
1580
1581 if (armedRemove === key) {
1582 // The inline are-you-sure beat (never a browser confirm()): the row's
1583 // own controls become the question.
1584 const yes = document.createElement("button");
1585 yes.type = "button";
1586 yes.className = "pill danger-ink";
1587 yes.textContent = "Remove";
1588 yes.setAttribute("aria-label", "Yes, remove " + longName);
1589 yes.addEventListener("click", () => {
1590 armedRemove = "";
1591 renderMethods(); // disarm before the (async) removal repaints
1592 focusMethodRow(key, "button"); // keep focus off <body> mid-flight
1593 performRemove(a);
1594 });
1595 const no = document.createElement("button");
1596 no.type = "button";
1597 no.className = "pill";
1598 no.textContent = "Keep";
1599 no.setAttribute("aria-label", "Keep " + longName);
1600 no.addEventListener("click", () => {
1601 armedRemove = "";
1602 renderMethods();
1603 focusMethodRow(key, "button");
1604 });
1605 li.append(yes, no);
1606 } else if (editingLabel !== key) {
1607 li.append(rowKebab(key, def, longName, a));
1608 }
1609 return li;
1610}
1611
1612// The inline label editor (never a browser prompt): the row's sub-line
1613// becomes a small form. Enter saves, Escape or Cancel keeps the old label.
1614function labelEditor(key, current, longName) {
1615 const form = document.createElement("form");
1616 form.className = "label-edit";
1617 const input = document.createElement("input");
1618 input.type = "text";
1619 input.maxLength = 60;
1620 input.value = current;
1621 input.placeholder = "e.g. personal, work";
1622 input.setAttribute("aria-label", "Label for " + longName);
1623 const save = document.createElement("button");
1624 save.type = "submit";
1625 save.className = "pill";
1626 save.textContent = "Save";
1627 const cancel = document.createElement("button");
1628 cancel.type = "button";
1629 cancel.className = "pill";
1630 cancel.textContent = "Cancel";
1631 cancel.setAttribute("aria-label", "Cancel editing the label for " + longName);
1632 const done = () => { editingLabel = ""; renderMethods(); focusMethodRow(key, ".icon-kebab"); };
1633 form.addEventListener("submit", async (e) => {
1634 e.preventDefault();
1635 save.disabled = cancel.disabled = true;
1636 try {
1637 await setMethodLabel(key, input.value.trim());
1638 done();
1639 } catch {
1640 save.disabled = cancel.disabled = false;
1641 methodsStatus("Couldn’t save the label — try again.", "error");
1642 }
1643 });
1644 cancel.addEventListener("click", done);
1645 input.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); done(); } });
1646 form.append(input, save, cancel);
1647 return form;
1648}
1649
1650// rowKebab is the row's own menu — the account kebab's vocabulary scaled
1651// down: aria-haspopup/expanded, focus onto the first item, Escape and
1652// outside-click both close (the shared manager keeps one open at a time).
1653// It holds "Add another" (when the kind's door is live) and Remove.
1654function rowKebab(key, def, longName, a) {
1655 const wrap = document.createElement("div");
1656 wrap.className = "row-kebab-wrap";
1657 const btn = document.createElement("button");
1658 btn.type = "button";
1659 btn.className = "icon-kebab";
1660 btn.setAttribute("aria-haspopup", "menu");
1661 btn.setAttribute("aria-expanded", "false");
1662 btn.setAttribute("aria-label", "Options for " + longName);
1663 btn.innerHTML = KEBAB_SVG;
1664 const menu = document.createElement("div");
1665 menu.className = "menu-pop hidden";
1666 menu.setAttribute("role", "menu");
1667 menu.setAttribute("aria-label", "Options for " + longName);
1668 if (def && def.enabled()) {
1669 const add = document.createElement("button");
1670 add.type = "button";
1671 add.setAttribute("role", "menuitem");
1672 // Visibly just "Add another" — the row names the kind. The accessible
1673 // name carries it for menus read out of visual context ("Add another
1674 // Passkey"); label-in-name holds, the visible text is its prefix.
1675 add.textContent = "Add another";
1676 add.setAttribute("aria-label", "Add another " + (def.name || a.kind));
1677 add.addEventListener("click", (e) => { closeRowMenu(false); def.start(e); });
1678 menu.append(add);
1679 }
1680 if (canLabel()) {
1681 const lab = document.createElement("button");
1682 lab.type = "button";
1683 lab.setAttribute("role", "menuitem");
1684 lab.textContent = methodLabel(key) ? "Edit label" : "Add a label";
1685 lab.setAttribute("aria-label", (methodLabel(key) ? "Edit the label for " : "Add a label for ") + longName);
1686 lab.addEventListener("click", () => {
1687 closeRowMenu(false);
1688 editingLabel = key;
1689 armedRemove = "";
1690 renderMethods();
1691 focusMethodRow(key, "input");
1692 });
1693 menu.append(lab);
1694 }
1695 const rm = document.createElement("button");
1696 rm.type = "button";
1697 rm.setAttribute("role", "menuitem");
1698 rm.className = "danger-ink";
1699 rm.textContent = "Remove";
1700 rm.setAttribute("aria-label", "Remove " + longName);
1701 rm.addEventListener("click", () => {
1702 closeRowMenu(false);
1703 armedRemove = key;
1704 editingLabel = "";
1705 renderMethods();
1706 focusMethodRow(key, "button.danger-ink"); // the are-you-sure's Remove
1707 });
1708 menu.append(rm);
1709 btn.addEventListener("pointerdown", () => { rowMenuPointerOpen = true; });
1710 btn.addEventListener("click", () => toggleRowMenu(btn, menu));
1711 wrap.append(btn, menu);
1712 return wrap;
1713}
1714
1715// One row menu open at a time, with the account menu's open/close manners
1716// (including its pointer-open focus-ring courtesy).
1717let openRowMenuState = null; // { btn, menu } | null
1718let rowMenuPointerOpen = false;
1719function toggleRowMenu(btn, menu) {
1720 if (openRowMenuState && openRowMenuState.menu === menu) { closeRowMenu(true); return; }
1721 closeRowMenu(false);
1722 menu.classList.toggle("pointer-open", rowMenuPointerOpen);
1723 rowMenuPointerOpen = false;
1724 menu.classList.remove("hidden");
1725 btn.setAttribute("aria-expanded", "true");
1726 openRowMenuState = { btn, menu };
1727 menu.querySelector('[role="menuitem"]').focus();
1728 addEventListener("pointerdown", rowMenuOutside, true);
1729 addEventListener("keydown", rowMenuKey, true);
1730}
1731function closeRowMenu(refocus) {
1732 if (!openRowMenuState) return;
1733 openRowMenuState.menu.classList.add("hidden");
1734 openRowMenuState.btn.setAttribute("aria-expanded", "false");
1735 if (refocus) openRowMenuState.btn.focus();
1736 openRowMenuState = null;
1737 removeEventListener("pointerdown", rowMenuOutside, true);
1738 removeEventListener("keydown", rowMenuKey, true);
1739}
1740function rowMenuOutside(e) {
1741 if (!e.target.closest || !e.target.closest(".row-kebab-wrap")) closeRowMenu(false);
1742}
1743function rowMenuKey(e) {
1744 if (e.key === "Escape") { e.preventDefault(); closeRowMenu(true); }
1745}
1746
1747// focusMethodRow puts focus back where a re-render dropped it.
1748function focusMethodRow(key, selector) {
1749 for (const li of $("methods-list").querySelectorAll("li")) {
1750 if (li.dataset.key === key) { li.querySelector(selector)?.focus(); return; }
1751 }
1752}
1753
1754// performRemove: a fresh proof first — SERIALLY, via the confirm step when
1755// the pocket is empty (list → confirm → back to the list → the DELETE) —
1756// then doRemoveAnchor. The server's refusals — notably the last-method 409
1757// ("that's the last way into this account…") — show inline, verbatim.
1758function performRemove(a) {
1759 const p = freshProof();
1760 if (p) { doRemoveAnchor(a, p); return; }
1761 // armed: the are-you-sure was just passed, so an in-page confirm completes
1762 // the removal; the round-trip serialization drops the flag on purpose.
1763 pendingIntent = { kind: "remove", anchorKind: a.kind, id: a.id, armed: true };
1764 openMethodsConfirmStep();
1765}
1766
1767async function doRemoveAnchor(a, proof) {
1768 methodsStatus("Removing…", "busy");
1769 try {
1770 await apiJSON("/api/anchors/" + encodeURIComponent(a.kind) + "/" + encodeURIComponent(a.id), {
1771 method: "DELETE",
1772 headers: { "X-Ceremony-Proof": proof },
1773 });
1774 methodsStatus("Removed.", "ok");
1775 setMethodLabel(a.kind + "/" + a.id, "").catch(() => { /* orphaned label — harmless */ });
1776 } catch (e) {
1777 methodsStatus((e && e.message) || "Couldn't remove it.", "error");
1778 }
1779 await loadAnchors(); // either way — a refusal means the list needs truth
1780 // The focused row may just have vanished — never strand focus on <body>.
1781 if (document.activeElement === document.body) $("methods-title").focus();
1782}
1783
1784function resumeIntent() {
1785 const intent = pendingIntent;
1786 pendingIntent = null;
1787 const p = freshProof();
1788 if (intent && p) runIntent(intent, p);
1789}
1790
1791// ---- the confirm step -----------------------------------------------------------
1792
1793// The confirm rungs, in fallback order; last-used (the #1112 marker) is
1794// promoted to the front. Each rung answers "can this account actually run
1795// it" from the anchors listing — the server takes a fresh proof from ANY
1796// door equally (stateless.go's ceremonyUser), so the step offers every one
1797// the account has, never passkey-or-bust. Apple doubles as the fallback for
1798// an account whose listing hasn't loaded (the pre-anchors shape).
1799const CONFIRM_RUNGS = ["passkey", "apple", "google", "microsoft", "github", "email"];
1800function confirmRungAvailable(rung) {
1801 const kinds = new Set((anchors || []).map((x) => x.kind));
1802 if (rung === "passkey") return !!window.PublicKeyCredential && kinds.has("passkey");
1803 if (rung === "apple") return DOORS.apple && kinds.has("siwa");
1804 // google | microsoft | github | email — anchor kinds verbatim, gated by the
1805 // door being LIVE on this build (a dark door can't run its ceremony).
1806 return DOORS[rung] && kinds.has(rung);
1807}
1808
1809// openMethodsConfirmStep is the serial "it's really you" screen, FIRST
1810// whenever an action needs a proof the pocket doesn't hold. Every door the
1811// account has shows, last-used first; the email rung unfolds into its code
1812// mini-form (immediately when it's the only rung — no pointless extra tap).
1813function openMethodsConfirmStep(note) {
1814 resetMethodsSteps(); // fresh forms; the parked change rides pendingIntent
1815 showMethodsScreen("methods-confirm");
1816 let rungs = CONFIRM_RUNGS.filter(confirmRungAvailable);
1817 if (!rungs.length) rungs = ["apple"];
1818 let last = "";
1819 try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* fallback order stands */ }
1820 if (rungs.includes(last)) rungs = [last, ...rungs.filter((r) => r !== last)];
1821 const btns = {
1822 passkey: $("methods-confirm-passkey"), apple: $("methods-confirm-apple"),
1823 google: $("methods-confirm-google"), microsoft: $("methods-confirm-microsoft"),
1824 github: $("methods-confirm-github"), email: $("methods-confirm-email"),
1825 };
1826 for (const [rung, btn] of Object.entries(btns)) btn.classList.toggle("hidden", !rungs.includes(rung));
1827 // DOM order is focus order — append MOVES the wired nodes into rung order.
1828 for (const rung of rungs) $("methods-confirm-actions").append(btns[rung]);
1829 const why = $("methods-confirm-why");
1830 if (note) confirmStatus(note, "error");
1831 if (rungs.length === 1 && rungs[0] === "email") {
1832 btns.email.classList.add("hidden"); // the one rung — straight to its form
1833 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.";
1834 $("methods-cemail").classList.remove("hidden");
1835 $("methods-cemail-addr").focus();
1836 } else {
1837 why.textContent = rungs.length === 1
1838 ? "Changing your sign-in options needs a quick check — confirm it's you and you'll carry straight on."
1839 : "Changing your sign-in options needs a quick check — confirm it's you with any of these and you'll carry straight on.";
1840 btns[rungs[0]].focus();
1841 }
1842}
1843
1844// The email rung's tap unfolds the code mini-form in place — the other
1845// rungs stay, so a mind-change needs no back-and-forth.
1846function confirmMethodsEmail() {
1847 $("methods-confirm-email").classList.add("hidden");
1848 $("methods-cemail").classList.remove("hidden");
1849 $("methods-cemail-addr").focus();
1850}
1851
1852async function confirmMethodsPasskey() {
1853 const btn = $("methods-confirm-passkey");
1854 btn.disabled = true;
1855 confirmStatus("Confirming it’s you…", "busy");
1856 try {
1857 // Only the proof is kept — this tab's session stays as it is (picking a
1858 // different account's passkey just makes the follow-on call miss, said
1859 // inline on the list).
1860 const { proof } = await runLogin(loginBegin);
1861 noteProof(proof);
1862 resumeIntent();
1863 } catch (e) {
1864 confirmStatus(friendlyCeremonyError(e, "signin").message, "error");
1865 } finally {
1866 prefetchLogin(); // ceremony options are single-use — ready the next
1867 btn.disabled = false;
1868 }
1869}
1870
1871function confirmMethodsApple(e) {
1872 // A fresh Apple sign-in mints the proof (finishSiwa pockets it); the
1873 // markers route the return leg straight back into the flow: methodsReturn
1874 // lands on the step family, and the serialized intent RESUMES the parked
1875 // change (the add step opens, a removal re-arms) instead of the bare list.
1876 const intent = intentToken(pendingIntent);
1877 pendingIntent = null; // payloads can't survive the navigation; the token rides
1878 startAppleSignIn(e, { methodsReturn: true, intent });
1879}
1880
1881// confirmMethodsOidc — the Apple confirm through an OIDC web door: the same
1882// marker-routed round trip (finishOidc pockets the proof, methodsReturn
1883// lands back on the step family, the intent resumes). select_account so a
1884// person with several provider accounts can pick the linked one.
1885function confirmMethodsOidc(provider, e) {
1886 const intent = intentToken(pendingIntent);
1887 pendingIntent = null; // payloads can't survive the navigation; the token rides
1888 startOidcSignIn(provider, e, { methodsReturn: true, intent, select: true });
1889}
1890
1891// The confirm step's email re-verify: the same two-step form as the door,
1892// but the answer's TOKEN is deliberately dropped — the proof is the point,
1893// and this tab keeps the session it already has.
1894async function cemailSend(e) {
1895 e.preventDefault();
1896 const email = $("methods-cemail-addr").value.trim();
1897 const btn = $("methods-cemail-send");
1898 btn.disabled = true;
1899 btn.classList.add("loading");
1900 confirmStatus("Sending your code…", "busy");
1901 const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door
1902 try {
1903 await Promise.all([apiJSON("/api/email/request", {
1904 method: "POST",
1905 headers: { "Content-Type": "application/json" },
1906 body: JSON.stringify({ email }),
1907 }), spinFloor]);
1908 confirmStatus("Check your email for a 6-digit code.", "");
1909 $("methods-cemail-form").classList.add("hidden");
1910 $("methods-cemail-code-form").classList.remove("hidden");
1911 $("methods-cemail-code").focus();
1912 } catch (err) {
1913 confirmStatus(err.message || "Couldn’t send the code — try again.", "error");
1914 } finally {
1915 btn.disabled = false;
1916 btn.classList.remove("loading");
1917 }
1918}
1919
1920async function cemailVerify(e) {
1921 e.preventDefault();
1922 const email = $("methods-cemail-addr").value.trim();
1923 const code = $("methods-cemail-code").value.trim();
1924 const btn = $("methods-cemail-verify");
1925 btn.disabled = true;
1926 btn.classList.add("loading");
1927 confirmStatus("Checking your code…", "busy");
1928 try {
1929 const res = await apiJSON("/api/email/verify", {
1930 method: "POST",
1931 headers: { "Content-Type": "application/json" },
1932 body: JSON.stringify({ email, code }),
1933 });
1934 noteProof(res.proof);
1935 resumeIntent();
1936 } catch (err) {
1937 confirmStatus(err.message || "That code didn’t work — try again.", "error");
1938 } finally {
1939 btn.disabled = false;
1940 btn.classList.remove("loading");
1941 }
1942}
1943
1944// ---- the add steps --------------------------------------------------------------
1945
1946// startAddApple begins the ADD-APPLE round trip: a fresh proof first —
1947// serially, via the confirm step when needed — then the ordinary Apple
1948// hand-off with the link marker, so the #siwa= return posts link:true
1949// instead of signing in (the proof rides sessionStorage beside the state;
1950// the server holds it to the same 5-minute window).
1951function startAddApple(e) {
1952 const src = e && e.currentTarget;
1953 const go = (proof) => startAppleSignIn(
1954 // The pressed button spins when it's still on screen (the big first-add
1955 // button); a menu-born add has no visible button left, so a detached one
1956 // absorbs the spinner and the smoke cover carries the wait.
1957 { currentTarget: src && src.isConnected ? src : document.createElement("button") },
1958 { linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } });
1959 const p = freshProof();
1960 if (p) { go(p); return; }
1961 pendingIntent = { kind: "add-apple" };
1962 openMethodsConfirmStep();
1963}
1964
1965// startAddOidc — the ADD round trip through any OIDC web door.
1966function startAddOidc(provider, e) {
1967 const src = e && e.currentTarget;
1968 const go = (proof) => startOidcSignIn(provider,
1969 { currentTarget: src && src.isConnected ? src : document.createElement("button") },
1970 { select: true, linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } });
1971 const p = freshProof();
1972 if (p) { go(p); return; }
1973 pendingIntent = { kind: "add-" + provider };
1974 openMethodsConfirmStep();
1975}
1976function startAddGoogle(e) { startAddOidc("google", e); }
1977
1978// startAddEmail / startAddPasskey open their serial card steps — confirm
1979// first when the pocket is empty (list → confirm → the add step → list).
1980function startAddEmail() {
1981 if (freshProof()) { openMethodsEmailStep(); return; }
1982 pendingIntent = { kind: "add-email" };
1983 openMethodsConfirmStep();
1984}
1985function startAddPasskey() {
1986 if (freshProof()) { openMethodsPasskeyStep(); return; }
1987 pendingIntent = { kind: "add-passkey" };
1988 openMethodsConfirmStep();
1989}
1990
1991function openMethodsEmailStep() {
1992 resetMethodsSteps();
1993 showMethodsScreen("methods-email");
1994 $("methods-email-addr").focus();
1995}
1996
1997async function methodsEmailSend(e) {
1998 e.preventDefault();
1999 const email = $("methods-email-addr").value.trim();
2000 const btn = $("methods-email-send");
2001 btn.disabled = true;
2002 btn.classList.add("loading");
2003 emailStepStatus("Sending your code…", "busy");
2004 const spinFloor = new Promise((r) => setTimeout(r, 900));
2005 try {
2006 await Promise.all([apiJSON("/api/email/request", {
2007 method: "POST",
2008 headers: { "Content-Type": "application/json" },
2009 body: JSON.stringify({ email }),
2010 }), spinFloor]);
2011 emailStepStatus("Check your email for a 6-digit code.", "");
2012 $("methods-email-form").classList.add("hidden");
2013 $("methods-email-code-form").classList.remove("hidden");
2014 $("methods-email-code").focus();
2015 } catch (err) {
2016 emailStepStatus(err.message || "Couldn’t send the code — try again.", "error");
2017 } finally {
2018 btn.disabled = false;
2019 btn.classList.remove("loading");
2020 }
2021}
2022
2023async function methodsEmailVerify(e) {
2024 e.preventDefault();
2025 const email = $("methods-email-addr").value.trim();
2026 const code = $("methods-email-code").value.trim();
2027 const proof = freshProof();
2028 if (!proof) {
2029 // The proof lapsed while the code round-tripped. The code survives —
2030 // link mode gates on the proof BEFORE redeeming (email.go) — so run the
2031 // confirm step again, then retry this exact verify.
2032 pendingIntent = { kind: "verify-email", email, code };
2033 openMethodsConfirmStep("That took a little while — confirm it’s you again.");
2034 return;
2035 }
2036 const btn = $("methods-email-verify");
2037 btn.disabled = true;
2038 btn.classList.add("loading");
2039 emailStepStatus("Checking your code…", "busy");
2040 try {
2041 await finishEmailAdd(email, code, proof);
2042 } finally {
2043 btn.disabled = false;
2044 btn.classList.remove("loading");
2045 }
2046}
2047
2048// finishEmailAdd posts the link verify and lands the outcome: success goes
2049// back to the list with the good news; a refusal shows wherever the person
2050// is (the add step, or the list after a confirm-step retry), verbatim. The
2051// 409 ("that address already signs in somewhere else") is the COMBINE
2052// doorway (#1098): the error line grows the offer that opens the combine
2053// step — keyed off the status, never off message text.
2054async function finishEmailAdd(email, code, proof) {
2055 const res = await fetch("/api/email/verify", {
2056 method: "POST",
2057 headers: { "Content-Type": "application/json", "X-Ceremony-Proof": proof },
2058 body: JSON.stringify({ email, code, link: true }),
2059 });
2060 if (res.ok) {
2061 labelNewEmail(email); // fire-and-forget: the address becomes the row's E2EE label
2062 backToMethodsList("Email added — you can sign in with it now.", "ok");
2063 return;
2064 }
2065 const msg = ((await res.text().catch(() => "")) || "").trim() || "That code didn’t work — try again.";
2066 const onEmailStep = !$("methods-email").classList.contains("hidden");
2067 if (onEmailStep) emailStepStatus(msg, "error"); else backToMethodsList(msg, "error");
2068 if (res.status === 409) {
2069 combine = { kind: "email", email };
2070 showCombineOffer(onEmailStep ? "methods-email-status" : "methods-status");
2071 }
2072}
2073
2074// The add-passkey step: one button, so navigator.credentials.create fires
2075// inside a fresh tap (Safari drops the activation across a network await).
2076// The register options are fetched WITH the proof at step open — that's what
2077// makes the new passkey attach to THIS account instead of minting a fresh
2078// one (webauthn.go's register/begin).
2079let passkeyAddBegin = null;
2080
2081function openMethodsPasskeyStep() {
2082 resetMethodsSteps();
2083 showMethodsScreen("methods-passkey");
2084 $("methods-passkey-create").focus();
2085 prefetchPasskeyAddBegin();
2086}
2087
2088function prefetchPasskeyAddBegin() {
2089 const proof = freshProof();
2090 if (!proof) return;
2091 apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } })
2092 .then((v) => { passkeyAddBegin = v; })
2093 .catch(() => { passkeyAddBegin = null; });
2094}
2095
2096async function addPasskeyCeremony() {
2097 const btn = $("methods-passkey-create");
2098 btn.disabled = true;
2099 passkeyStepStatus("Creating your passkey…", "busy");
2100 let begin = passkeyAddBegin;
2101 passkeyAddBegin = null; // ceremony options are single-use
2102 try {
2103 if (!begin) {
2104 const proof = freshProof();
2105 if (!proof) {
2106 // Sat on this step past the window — the confirm step runs again,
2107 // then reopens here with fresh proof-carrying options.
2108 pendingIntent = { kind: "add-passkey" };
2109 openMethodsConfirmStep("That took a little while — confirm it’s you again.");
2110 return;
2111 }
2112 // The prefetch missed (slow network); fetching here may cost Safari's
2113 // activation — the retry tap then uses the fresh prefetch below.
2114 begin = await apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } });
2115 }
2116 const reg = await runRegister(begin);
2117 noteProof(reg.proof);
2118 // When this tab holds the DEK and a blob already exists, wrap it for the
2119 // new credential too — the passkey then OPENS the list, not just signs
2120 // in. Sealed/anchor tabs (and blob-less accounts — an empty vault save
2121 // would trip the creation-eligibility gate, see createPasskey) skip it:
2122 // identity-only is still a real way in under the anchor model.
2123 if (dekKey && vault && vault.version !== "0" && reg.prf) {
2124 try {
2125 const kek = await kekFromPRF(reg.prf);
2126 vault.keys[reg.credId] = await wrapDEK(dekKey, kek);
2127 await saveVault();
2128 } catch { /* the passkey works for sign-in; the wrap can land later */ }
2129 }
2130 backToMethodsList("Passkey added — one tap next time on this device.", "ok");
2131 } catch (e) {
2132 prefetchPasskeyAddBegin(); // options were spent — ready a retry
2133 if (e && e.message === "no-prf") {
2134 passkeyStepStatus("This device can’t protect your sign-in here (no PRF support) — try a different device or password manager.", "error");
2135 } else {
2136 passkeyStepStatus(formatCeremonyError(e, "create"), "error");
2137 }
2138 } finally {
2139 btn.disabled = false;
2140 }
2141}
2142
2143// ---- combine your accounts (#1098's merge) --------------------------------------
2144
2145// The in-flight combine context, memory-only: which anchor kind collided
2146// ("email" | "apple"), the typed address (email kind), and — once stage A
2147// proves the other side — the ABSORBED account's credentials. Those are
2148// deliberately never adopted as the session and never persisted: this tab
2149// stays signed into the SURVIVOR; the absorbed token+proof exist only to
2150// move its vault and feed absorb_proof to POST /api/account/merge
2151// (merge.go). Cancel anywhere just drops this object — nothing destructive
2152// happens before the explicit beats.
2153let combine = null; // { kind, email?, absorbedToken?, absorbedProof?, absorbedExpires?, absorbedBlob?, absorbedPasskeyIds? }
2154
2155function combineStatus(msg, kind) {
2156 const el = $("combine-status");
2157 el.textContent = msg || "";
2158 el.className = "status" + (kind ? " " + kind : "");
2159}
2160
2161// showCombineOffer grows the offer under whichever status line carries the
2162// 409 — the node moves beside it (like #newhere follows its surface) and
2163// resets hide it again.
2164function showCombineOffer(statusId) {
2165 $(statusId).insertAdjacentElement("afterend", $("combine-offer"));
2166 $("combine-offer").classList.remove("hidden");
2167}
2168
2169// absorbedProofFresh mirrors freshProof for the OTHER side's proof — the
2170// merge and the absorbed-blob delete both refuse a stale one server-side,
2171// so a lapsed proof re-runs the prove stage instead of burning a call.
2172function absorbedProofFresh() {
2173 return !!(combine && combine.absorbedProof && combine.absorbedExpires - PROOF_MARGIN_MS > Date.now());
2174}
2175
2176// openCombineStep is the serial combine screen. Stage A (prove the other
2177// account) when the absorbed side is still unproven; a proven side goes
2178// straight to stage B (its spaces). The Apple prove is a full round trip
2179// (merge-apple intent), so its return re-enters here already proven.
2180function openCombineStep() {
2181 if (!combine) { backToMethodsList("", "", { reload: false }); return; }
2182 resetMethodsSteps();
2183 showMethodsScreen("methods-combine");
2184 $("combine-title").focus();
2185 if (absorbedProofFresh()) { combineVaultStage(); return; }
2186 combine.absorbedProof = ""; // a stale proof re-proves from scratch
2187 $("combine-prove").classList.remove("hidden");
2188 if (combine.kind === "email") {
2189 // The add-flow's code was spent by the 409 (the redeem happens before
2190 // the conflict answer), so a FRESH code proves the other side — say so.
2191 $("combine-prove-note").textContent =
2192 "First, prove the other account is yours: we’re sending another code to " + combine.email + ".";
2193 $("combine-code").classList.remove("hidden");
2194 combineRequestCode();
2195 $("combine-code-input").focus();
2196 } else if (combine.kind === "apple") {
2197 // The button says it all (Paul: no "First, prove…" preamble); only the
2198 // email prove keeps its note — it names where the code went.
2199 $("combine-prove-note").textContent = "";
2200 $("combine-apple-go").classList.remove("hidden");
2201 $("combine-apple-go").focus();
2202 } else {
2203 // A provider kind (google/microsoft/github): one generic go button,
2204 // dressed per provider — brand class, display name, cloned glyph.
2205 const p = OIDC_WEB[combine.kind];
2206 $("combine-prove-note").textContent = "";
2207 const go = $("combine-oidc-go");
2208 go.className = { google: "google-btn", microsoft: "ms-btn", github: "gh-btn" }[combine.kind];
2209 go.textContent = "Continue with " + p.display;
2210 const glyph = kindGlyph(combine.kind);
2211 if (glyph) go.prepend(glyph);
2212 go.focus();
2213 }
2214}
2215
2216async function combineRequestCode() {
2217 combineStatus("Sending a code…", "busy");
2218 try {
2219 await apiJSON("/api/email/request", {
2220 method: "POST",
2221 headers: { "Content-Type": "application/json" },
2222 body: JSON.stringify({ email: combine.email }),
2223 });
2224 combineStatus("We sent another code to prove that account is yours — check your email.", "");
2225 } catch (e) {
2226 combineStatus((e && e.message) || "Couldn’t send the code — try again.", "error");
2227 }
2228}
2229
2230// The prove-by-code verify runs in SIGN-IN mode (link:false): the answer is
2231// the absorbed account's token + proof, held in memory only (see combine).
2232async function combineCodeVerify(e) {
2233 e.preventDefault();
2234 const code = $("combine-code-input").value.trim();
2235 const btn = $("combine-code-verify");
2236 btn.disabled = true;
2237 btn.classList.add("loading");
2238 combineStatus("Checking your code…", "busy");
2239 const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door
2240 try {
2241 const [res] = await Promise.all([apiJSON("/api/email/verify", {
2242 method: "POST",
2243 headers: { "Content-Type": "application/json" },
2244 body: JSON.stringify({ email: combine.email, code }),
2245 }), spinFloor]);
2246 combine.absorbedToken = res.token;
2247 combine.absorbedProof = res.proof;
2248 combine.absorbedExpires = Date.now() + PROOF_TTL_MS;
2249 combineStatus("");
2250 combineVaultStage();
2251 } catch (err) {
2252 combineStatus((err && err.message) || "That code didn’t work — try again.", "error");
2253 } finally {
2254 btn.disabled = false;
2255 btn.classList.remove("loading");
2256 }
2257}
2258
2259// The prove-by-Apple leg: a full round trip carrying the merge-apple intent;
2260// finishSiwaMergeProve picks the return up and re-enters the step proven.
2261function combineAppleProve(e) {
2262 startAppleSignIn(e, { intent: "merge-apple" });
2263}
2264
2265// kindGlyph clones a kind's brand mark from its all-ways button — one
2266// source per glyph (static, in-code SVG), sized for inline button use.
2267function kindGlyph(kind) {
2268 const src = $("otherways-" + (kind === "siwa" ? "apple" : kind));
2269 const svg = src && src.querySelector("svg");
2270 if (!svg) return null;
2271 const c = svg.cloneNode(true);
2272 c.classList.remove("pk-ico");
2273 c.classList.add("btn-ico");
2274 return c;
2275}
2276
2277// Stage B: the absorbed account's saved spaces. Nothing to move (no blob)
2278// skips straight to the merge; a real blob offers the honest choices —
2279// unlock with ITS passkey and bring them over, or (behind an inline
2280// are-you-sure) combine without them, destroying its sealed list.
2281async function combineVaultStage() {
2282 $("combine-prove").classList.add("hidden");
2283 $("combine-code").classList.add("hidden");
2284 $("combine-apple-go").classList.add("hidden");
2285 $("combine-oidc-go").classList.add("hidden");
2286 $("combine-skip-confirm").classList.add("hidden");
2287 combineStatus("Checking the other account’s spaces…", "busy");
2288 let got;
2289 try {
2290 got = await fetchBlob(combine.absorbedToken);
2291 if (got) {
2292 const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + combine.absorbedToken } });
2293 combine.absorbedPasskeyIds = (r.anchors || []).filter((x) => x.kind === "passkey").map((x) => x.id);
2294 }
2295 } catch (e) {
2296 combineStatus((e && e.message) || "Couldn’t reach the other account — try again.", "error");
2297 return;
2298 }
2299 if (!got) { combineFinish(0); return; } // (a) nothing to move
2300 combine.absorbedBlob = got;
2301 combineStatus("");
2302 const canCeremony = !!window.PublicKeyCredential && combine.absorbedPasskeyIds.length > 0;
2303 // The union needs the SURVIVOR side open too: its DEK in hand and at least
2304 // one wrapped copy of it to seal under (a keys-less PUT would mint a blob
2305 // nobody could ever open).
2306 const canUnion = !!dekKey && !!vault && Object.keys(vault.keys || {}).length > 0;
2307 $("combine-vault").classList.remove("hidden");
2308 $("combine-unlock").classList.toggle("hidden", !(canCeremony && canUnion));
2309 $("combine-skip").classList.remove("hidden");
2310 const note = $("combine-vault-note");
2311 if (canCeremony && canUnion) {
2312 note.textContent = "The other account has saved spaces. Unlock them with that account’s passkey and they’ll move over with everything else.";
2313 $("combine-unlock").focus();
2314 prefetchLogin(); // the unlock ceremony wants gesture-fresh options
2315 } else if (!canUnion) {
2316 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.";
2317 $("combine-skip").focus();
2318 } else {
2319 note.textContent = "The other account has saved spaces, but it has no passkey to unlock them, so they can’t be brought over.";
2320 $("combine-skip").focus();
2321 }
2322}
2323
2324// combineUnlock runs the ABSORBED account's passkey ceremony, decrypts its
2325// vault, unions it into the survivor's (survivor wins ties, tombstones from
2326// either side hold, exactly saveVault's race rules), re-encrypts under the
2327// survivor's DEK, deletes the absorbed blob, then merges.
2328async function combineUnlock() {
2329 const btn = $("combine-unlock");
2330 btn.disabled = true;
2331 combineStatus("Confirm with the OTHER account’s passkey…", "busy");
2332 let fin;
2333 try {
2334 fin = await runLogin(loginBegin);
2335 } catch (e) {
2336 combineStatus(friendlyCeremonyError(e, "signin").message, "error");
2337 prefetchLogin();
2338 btn.disabled = false;
2339 return;
2340 }
2341 prefetchLogin(); // options were spent either way
2342 // The ceremony MUST land on the absorbed account — its credential ids are
2343 // exactly the passkey ids its anchors listing gave us. A miss means the
2344 // wrong passkey was picked (likely this account's own): explain and retry.
2345 if (!combine.absorbedPasskeyIds.includes(fin.credId)) {
2346 combineStatus("That passkey opens a different account — choose the OTHER account’s passkey and try again.", "error");
2347 btn.disabled = false;
2348 return;
2349 }
2350 // Freshest absorbed credentials win (the ceremony just minted a proof).
2351 combine.absorbedToken = fin.token;
2352 combine.absorbedProof = fin.proof;
2353 combine.absorbedExpires = Date.now() + PROOF_TTL_MS;
2354 try {
2355 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.");
2356 const kek = await kekFromPRF(fin.prf);
2357 const wrapped = unpackBlob(combine.absorbedBlob.bytes).keys[fin.credId];
2358 if (!wrapped) throw new Error("That passkey can’t unlock the other account’s spaces.");
2359 const absDek = await unwrapDEK(wrapped, kek);
2360 const opened = await openBlob(combine.absorbedBlob.bytes, absDek);
2361 combineStatus("Bringing its spaces over…", "busy");
2362 // Union under saveVault's rules: absorbed first, survivor overlays
2363 // (survivor wins ties by url); tombstones from either side hold unless
2364 // the SURVIVOR's live list still carries the entry.
2365 const before = (vault.obj.servers || []).length;
2366 const merged = new Map((opened.obj.servers || []).map((sv) => [sv.url, sv]));
2367 for (const sv of vault.obj.servers || []) merged.set(sv.url, sv);
2368 const removed = mergeRemoved(opened.obj.removed, vault.obj.removed)
2369 .filter((t) => !(vault.obj.servers || []).some((sv) => sv.url === t.url));
2370 vault.obj.servers = [...merged.values()].filter((sv) => !removed.some((t) => t.url === sv.url));
2371 if (removed.length) vault.obj.removed = removed; else delete vault.obj.removed;
2372 // Method labels ride the union too: absorbed first, survivor overlays —
2373 // the moved-over anchors keep the names their old account gave them.
2374 const labels = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) };
2375 if (Object.keys(labels).length) vault.obj.methods = labels;
2376 const movedSpaces = vault.obj.servers.length - before;
2377 // The absorbed passkey just proved AND unlocked — after the merge it
2378 // signs into the survivor, so wrap the survivor's DEK for it too: it
2379 // should OPEN the combined list, not hit the sealed prompt.
2380 vault.keys[fin.credId] = await wrapDEK(dekKey, kek);
2381 await saveVault(); // CAS PUT of the survivor's union, retrying once
2382 await deleteAbsorbedBlob(); // client-first ordering: emptied before merge
2383 combineFinish(movedSpaces);
2384 } catch (e) {
2385 combineStatus((e && e.message) || "Couldn’t bring its spaces over.", "error");
2386 btn.disabled = false;
2387 }
2388}
2389
2390// The skip path's inline are-you-sure: destroying the absorbed account's
2391// sealed spaces list must be an explicit, plainly-worded beat of its own.
2392function combineSkip() {
2393 $("combine-unlock").classList.add("hidden");
2394 $("combine-skip").classList.add("hidden");
2395 $("combine-skip-confirm").classList.remove("hidden");
2396 $("combine-skip-yes").focus();
2397}
2398
2399async function combineSkipYes() {
2400 const btn = $("combine-skip-yes");
2401 btn.disabled = true;
2402 combineStatus("Clearing its spaces list…", "busy");
2403 try {
2404 await deleteAbsorbedBlob();
2405 combineFinish(0);
2406 } catch (e) {
2407 combineStatus((e && e.message) || "Couldn’t clear the other account’s spaces list.", "error");
2408 } finally {
2409 btn.disabled = false;
2410 }
2411}
2412
2413// deleteAbsorbedBlob is the ceremony-gated DELETE /api/blob, authed by the
2414// ABSORBED side's proof — merge.go's precondition (client-first ordering).
2415async function deleteAbsorbedBlob() {
2416 if (!absorbedProofFresh()) {
2417 openCombineStep(); // stale other-side proof: prove again, then retry
2418 combineStatus("That took a while — prove the other account again first.", "error");
2419 throw new Error("prove the other account again first");
2420 }
2421 const res = await fetch("/api/blob", { method: "DELETE", headers: { "X-Ceremony-Proof": combine.absorbedProof } });
2422 if (!res.ok && res.status !== 404) {
2423 throw new Error(((await res.text().catch(() => "")) || "").trim() || "Couldn’t clear the other account’s spaces list.");
2424 }
2425}
2426
2427// Stage C: the merge itself — both sides' fresh proofs (merge.go's
2428// both-sides-proofs auth), then the plain-words outcome back on the list.
2429async function combineFinish(movedSpaces = 0) {
2430 if (!combine) { backToMethodsList("", "", { reload: false }); return; }
2431 const sp = freshProof();
2432 if (!sp) {
2433 // The SURVIVOR's proof lapsed mid-combine: the confirm step runs, then
2434 // finishes right here (memory-only intent — a confirm-with-Apple trip
2435 // would lose the combine context, and intentToken serializes this to
2436 // nothing on purpose).
2437 pendingIntent = { kind: "combine-finish", movedSpaces };
2438 openMethodsConfirmStep("That took a while — confirm it’s you again to finish combining.");
2439 return;
2440 }
2441 if (!absorbedProofFresh()) {
2442 openCombineStep();
2443 combineStatus("That took a while — prove the other account again first.", "error");
2444 return;
2445 }
2446 showMethodsScreen("methods-combine");
2447 combineStatus("Combining the accounts…", "busy");
2448 let res;
2449 try {
2450 res = await fetch("/api/account/merge", {
2451 method: "POST",
2452 headers: {
2453 Authorization: "Bearer " + state.token,
2454 "X-Ceremony-Proof": sp,
2455 "Content-Type": "application/json",
2456 },
2457 body: JSON.stringify({ absorb_proof: combine.absorbedProof }),
2458 });
2459 } catch {
2460 combineStatus("Couldn’t reach the server — try again.", "error");
2461 return;
2462 }
2463 if (!res.ok) {
2464 // The server's plain refusals verbatim (same-account, still-holds-a-
2465 // backup, a stale proof), landed on this step for another go.
2466 combineStatus(((await res.text().catch(() => "")) || "").trim() || "Couldn’t combine the accounts.", "error");
2467 return;
2468 }
2469 const body = await res.json().catch(() => ({}));
2470 const movedMethods = (body.moved && (body.moved.anchors || 0) + (body.moved.passkeys || 0)) || 0;
2471 let msg = "Accounts combined — " + movedMethods.toLocaleString() + " sign-in method" +
2472 (movedMethods === 1 ? "" : "s") + " moved";
2473 if (movedSpaces > 0) {
2474 msg += ", " + movedSpaces.toLocaleString() + " space" + (movedSpaces === 1 ? "" : "s") + " brought over";
2475 }
2476 msg += ".";
2477 combine = null;
2478 backToMethodsList(msg, "ok"); // reloads the anchors — the moved methods appear
2479 if (movedSpaces > 0 && vault) provisioned = null; // the ledger may know the new spaces
2480}
2481
2482// ---- spaces list --------------------------------------------------------------
2483
2484// hideVault parks the spaces screen for a sibling screen (or sign-out). The
2485// siwa-arrival class comes off HERE, under display:none where it's free —
2486// removing it while the vault is visible changes the computed animation-name
2487// back to .screen's, and a changed name RESTARTS the entry animation on an
2488// already-visible card (Paul's after-flash). An open account menu doesn't
2489// survive the screen either.
2490function hideVault() {
2491 closeAccountMenu(false);
2492 $("vault").classList.add("hidden");
2493 $("vault").classList.remove("arrive");
2494}
2495
2496// Creating a space is for people already IN one: the + hides until the
2497// decrypted vault actually holds a space entry (an empty account's + only
2498// invited the server's 403 dead-end — creation is gated to members, and the
2499// sealed and empty states both hide it). The server enforces the same
2500// boundary on POST /api/spaces.
2501function canCreateSpace() {
2502 return spacesEnabled && !!(vault && (vault.obj.servers || []).length);
2503}
2504
2505function showVault() {
2506 if (consumeJoinReturn()) return; // pre-join sign-in: back to the join page, not the list
2507 $("signed-out").classList.add("hidden");
2508 $("reassure").classList.add("hidden");
2509 resetEmailDoor(); // arriving from the email door hides it
2510 resetOtherWays(); // …and from the all-ways step likewise
2511 $("create").classList.add("hidden");
2512 $("archive").classList.add("hidden");
2513 $("vault-sealed").classList.add("hidden");
2514 $("vault").classList.remove("hidden");
2515 $("open-create").classList.toggle("hidden", !canCreateSpace()); // the + to create
2516
2517 const box = $("spaces");
2518 box.textContent = "";
2519
2520 // Archived spaces are pulled out first — hidden from the main list, reachable
2521 // through the "Archived (N)" affordance at the foot.
2522 const { active, archived } = partitionArchived(vault.obj.servers || []);
2523
2524 // A just-archived space leaves an inline "Archived · Undo" banner (never a
2525 // floating toast); it survives re-renders and auto-clears on a timer.
2526 if (pendingUndo) box.append(undoBanner());
2527
2528 // Fuzzy search filters the active lists. It only appears once there's enough
2529 // to sift through; while a query is active, reordering is off (dragging a
2530 // filtered subset would be ambiguous) so the grips are hidden.
2531 const search = $("space-search");
2532 search.classList.toggle("hidden", active.length < 4);
2533 const query = search.classList.contains("hidden") ? "" : search.value.trim();
2534
2535 // The invite-only card belongs only to the truly-empty state — someone
2536 // with archived spaces already has Eleven.
2537 $("invite-note").classList.toggle("hidden", !!(active.length || archived.length));
2538 if (!active.length) {
2539 box.append(emptyLine(archived.length
2540 ? "All your spaces are archived — open the archive to bring one back."
2541 : "None (yet). Creating is invite-only for now."));
2542 // Truly empty + signed in through an anchor: this may be the wrong-door
2543 // landing — their spaces could live under an older passkey account.
2544 const m = sessionMethod();
2545 if (!archived.length && m && m !== "passkey" && window.PublicKeyCredential)
2546 box.append(bringOverLine());
2547 } else {
2548 const servers = query ? filterServers(active, query) : active;
2549 if (!servers.length) {
2550 box.append(emptyLine("No spaces match “" + query + "”."));
2551 } else {
2552 const sortable = !query;
2553 // Two independently-sorted lists: Starred (→ the app's rail) and the rest
2554 // (→ a "more" affordance). Headings appear only once something's starred.
2555 const { starred, others } = partitionServers(servers);
2556 if (starred.length) {
2557 box.append(sectionHeading("Starred"), serverList(starred, sortable));
2558 if (others.length) box.append(sectionHeading("More"));
2559 }
2560 if (others.length) box.append(serverList(others, sortable));
2561 }
2562 }
2563
2564 if (archived.length) box.append(archivedButton(archived.length));
2565
2566 // The ledger read behind the Upgrade affordance — lazy, once per sign-in.
2567 if (spacesEnabled && provisioned === null && state.token) refreshProvisioned();
2568
2569 // Back from a pay-first checkout — or a marker parked by a tab that closed
2570 // on Stripe's page: claim the space into existence. Once per session id per
2571 // page life (the error pane's Try again drives retries); the claim itself
2572 // is idempotent downstream, so a duplicate is harmless.
2573 {
2574 const marker = pendingCreateMarker();
2575 const session = paidCreateSession || (marker && marker.session) || "";
2576 const region = paidCreateRegion || (marker && marker.region) || "";
2577 paidCreateSession = "";
2578 paidCreateRegion = "";
2579 if (state.token && session && !claimedSessions.has(session)) {
2580 claimedSessions.add(session);
2581 claimPaidCreate(session, region);
2582 return; // the claim owns the screen now
2583 }
2584 }
2585
2586 // Back from a completed checkout: thank inline, then re-read the ledger a few
2587 // seconds later — the webhook that flips the badge can lag the redirect.
2588 if (upgradedHost) {
2589 vaultStatus("Payment received — " + upgradedHost + " is upgraded. Thank you!", "ok");
2590 upgradedHost = "";
2591 setTimeout(() => { provisioned = null; if (vault && !$("vault").classList.contains("hidden")) showVault(); }, 5000);
2592 }
2593
2594 // A pending #claim=<host> deep link resumes here, the first vault render
2595 // after sign-in — it needs only the account, not the provisioner ledger.
2596 consumeClaimLink();
2597}
2598
2599// refreshProvisioned learns which spaces this account created and their
2600// entitlement (the provisioner's ledger, via home). No answer just means no
2601// Upgrade affordance — the list renders fine without it.
2602function refreshProvisioned() {
2603 provisioned = {}; // in flight — re-renders don't refetch
2604 apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } })
2605 .then((r) => {
2606 billingTiers = (r.billing && r.billing.tiers) || [];
2607 billingEnabled = billingTiers.length > 0;
2608 provisioned = {};
2609 for (const sp of r.spaces || []) provisioned[sp.host] = sp;
2610 if (vault && !$("vault").classList.contains("hidden")) showVault();
2611 // The create plan step renders its picker from this book — fill it in
2612 // if the screen opened before the ledger answered.
2613 if (!$("create-plan").classList.contains("hidden")) renderPlanTiers();
2614 consumeUpgradeLink();
2615 })
2616 .catch(() => { /* ledger unreachable — render without the affordance */ });
2617}
2618
2619// consumeUpgradeLink honours a #upgrade=<host> deep link (the space menu's
2620// Upgrade entry) once the ledger is in: opens the tier picker for that space
2621// if it's this account's and still free. Cleared either way, so a stale
2622// fragment can't re-fire the sheet on later renders.
2623function consumeUpgradeLink() {
2624 if (!upgradeLinkHost || !vault) return;
2625 const target = upgradeLinkHost;
2626 upgradeLinkHost = "";
2627 history.replaceState(null, "", location.pathname + location.search);
2628 const s = (vault.obj.servers || []).find((sv) => {
2629 try { return new URL(sv.url).host.toLowerCase() === target; } catch { return false; }
2630 });
2631 const ledger = provisioned && provisioned[target];
2632 if (s && ledger && ledger.entitlement !== "paid") openUpgrade(s, true);
2633}
2634
2635// ---- archive: undo banner + entry point --------------------------------------
2636// pendingUndo holds the pre-archive servers snapshot so a single tap fully
2637// restores the entry (its star + exact position), not just the archived flag.
2638let pendingUndo = null; // { name, prev } | null
2639let undoTimer = 0;
2640
2641function clearUndo() {
2642 if (undoTimer) { clearTimeout(undoTimer); undoTimer = 0; }
2643 pendingUndo = null;
2644}
2645
2646function undoBanner() {
2647 const p = document.createElement("p");
2648 p.className = "undo-note";
2649 const span = document.createElement("span");
2650 span.textContent = "Archived " + pendingUndo.name + ". ";
2651 const btn = document.createElement("button");
2652 btn.type = "button";
2653 btn.className = "linkish";
2654 btn.textContent = "Undo";
2655 btn.addEventListener("click", undoArchive);
2656 p.append(span, btn);
2657 return p;
2658}
2659
2660// A muted archive-box glyph (static, in-code SVG — safe for innerHTML), matching
2661// the quiet grip: a lidded box with a handle slot.
2662const ARCHIVE_SVG =
2663 '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" ' +
2664 'stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
2665 '<rect x="2" y="2.5" width="12" height="3" rx="0.8"/>' +
2666 '<path d="M3 5.5v7a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-7"/>' +
2667 '<path d="M6.3 8.3h3.4"/></svg>';
2668
2669// The foot-of-list button that opens the archive; shown only when something's in
2670// it. The count keeps the archive discoverable without cluttering the main list.
2671function archivedButton(n) {
2672 const btn = document.createElement("button");
2673 btn.type = "button";
2674 btn.className = "archived-open";
2675 btn.addEventListener("click", openArchive);
2676 const label = document.createElement("span");
2677 label.innerHTML = ARCHIVE_SVG;
2678 const text = document.createElement("span");
2679 text.textContent = "Archived (" + n + ")";
2680 label.append(text);
2681 const chev = document.createElement("span");
2682 chev.className = "chev";
2683 chev.setAttribute("aria-hidden", "true");
2684 chev.textContent = "›";
2685 btn.append(label, chev);
2686 return btn;
2687}
2688
2689function archiveSpace(s) {
2690 clearUndo();
2691 const prev = vault.obj.servers;
2692 pendingUndo = { name: labelOf(s), prev }; // set before applyServers so the banner renders
2693 undoTimer = setTimeout(() => { pendingUndo = null; if (vault) showVault(); }, 8000);
2694 applyServers(setArchived(prev, s.url, true)); // re-renders (with the banner) + persists
2695}
2696
2697function undoArchive() {
2698 if (!pendingUndo) return;
2699 const prev = pendingUndo.prev;
2700 clearUndo();
2701 applyServers(prev); // restore the exact prior array (star + position), re-render, persist
2702}
2703
2704function emptyLine(text) {
2705 const p = document.createElement("p");
2706 p.className = "empty";
2707 p.textContent = text;
2708 return p;
2709}
2710
2711// Which method is holding THIS session up — the persisted session records it;
2712// the last-signin marker covers a tab-only (unpersisted) session. "" when
2713// unknowable.
2714function sessionMethod() {
2715 const s = persistedSession();
2716 if (s && s.method) return s.method;
2717 try { return localStorage.getItem("home_last_signin") || ""; } catch { return ""; }
2718}
2719
2720// The wrong-door landing: an Apple/email sign-in that resolves a brand-new
2721// account shows an empty list — but this person's spaces may live under the
2722// passkey they used before anchors existed. Offer the bridge in place: the
2723// ordinary passkey door, run from the signed-in view. signIn() touches no
2724// state until the ceremony succeeds, so cancelling leaves this session
2725// exactly as it was; success swaps to the passkey account, where Sign-in
2726// options → Add pulls this anchor over (the combine flow).
2727function bringOverLine() {
2728 const p = document.createElement("p");
2729 p.className = "empty";
2730 const b = document.createElement("button");
2731 b.type = "button";
2732 b.className = "passkey-link";
2733 b.textContent = "Sign in with your passkey";
2734 b.addEventListener("click", () => {
2735 b.disabled = true;
2736 vaultStatus("Confirming it’s you…", "busy");
2737 signIn().then(() => vaultStatus(""), () => {
2738 vaultStatus("That passkey didn’t confirm — nothing has changed.", "error");
2739 }).finally(() => { b.disabled = false; setSignedOutBusy(false); setStatus(""); });
2740 });
2741 p.append("Already have spaces? ", b, " to bring them over.");
2742 return p;
2743}
2744
2745function sectionHeading(text) {
2746 const h = document.createElement("div");
2747 h.className = "spaces-head";
2748 h.textContent = text;
2749 return h;
2750}
2751
2752function serverList(group, sortable) {
2753 const ul = document.createElement("ul");
2754 ul.className = "servers";
2755 for (const s of group) ul.append(serverRow(s, sortable));
2756 return ul;
2757}
2758
2759// A quiet 6-dot grip (static, in-code SVG — safe for innerHTML). iOS-style, it
2760// lives on the right so the star stays the left anchor; it surfaces on hover.
2761const GRIP_SVG =
2762 '<svg viewBox="0 0 10 16" width="10" height="16" aria-hidden="true">' +
2763 '<circle cx="2" cy="3" r="1.25"/><circle cx="8" cy="3" r="1.25"/>' +
2764 '<circle cx="2" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/>' +
2765 '<circle cx="2" cy="13" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>';
2766
2767function serverRow(s, sortable) {
2768 const li = document.createElement("li");
2769 li.dataset.url = s.url;
2770
2771 const star = document.createElement("button");
2772 star.type = "button";
2773 star.className = "star" + (s.starred ? " on" : "");
2774 star.textContent = s.starred ? "★" : "☆";
2775 star.setAttribute("aria-label", (s.starred ? "Unstar " : "Star ") + labelOf(s));
2776 // Star/reorder act on the ACTIVE list only; archived entries ride along at the
2777 // tail untouched (they never render a star or grip).
2778 star.addEventListener("click", () => {
2779 const { active, archived } = partitionArchived(vault.obj.servers);
2780 applyServers([...toggleStar(active, s.url), ...archived]);
2781 });
2782
2783 const main = document.createElement("div");
2784 main.className = "srv-main";
2785 const name = document.createElement("a");
2786 name.className = "name";
2787 // A clean browser has no per-space local session yet. The marker asks the
2788 // destination to begin its encrypted restore handoff instead of bouncing
2789 // straight back here; restore.js reuses this tab's unlocked home session.
2790 name.href = spaceRestoreURL(s.url);
2791 name.textContent = labelOf(s);
2792 // Spaces go by name here — the address stays off the row. It survives as a
2793 // hover tooltip, which also tells twin-named spaces apart. (Unnamed spaces
2794 // already show their host as the label, so the tooltip would just repeat it.)
2795 if (s.name) name.title = hostOf(s.url);
2796 main.append(name);
2797
2798 // Billing state, for spaces this account created (the provisioner's ledger):
2799 // upgraded rows carry a quiet Supporter badge; free ones offer Upgrade.
2800 const ledger = billingEnabled && provisioned ? provisioned[hostOf(s.url)] : null;
2801 if (ledger && ledger.entitlement === "paid") {
2802 // The tier badge with Manage right beside it — the subscription's label
2803 // and its controls read as one unit.
2804 const row = document.createElement("span");
2805 row.className = "badge-row";
2806 const badge = document.createElement("span");
2807 badge.className = "supporter-badge";
2808 badge.textContent = tierName(ledger.tier);
2809 const manage = document.createElement("button");
2810 manage.type = "button";
2811 manage.className = "row-manage";
2812 manage.textContent = "Manage";
2813 manage.setAttribute("aria-label", "Manage the subscription for " + labelOf(s));
2814 manage.addEventListener("click", () => openPortal(hostOf(s.url), manage));
2815 row.append(badge, manage);
2816 // A cancelled subscription runs out at paid_until — say so, quietly.
2817 if (ledger.cancel_at_period_end && ledger.paid_until) {
2818 const ends = document.createElement("span");
2819 ends.className = "sub-ends";
2820 ends.textContent = "ends " + new Date(ledger.paid_until * 1000)
2821 .toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
2822 row.append(ends);
2823 }
2824 main.append(row);
2825 }
2826
2827 // The storage meter's reading (spaces this account created; 0 = not yet
2828 // metered), with the space's storage allowance when one is set.
2829 const metered = provisioned ? provisioned[hostOf(s.url)] : null;
2830 if (metered && metered.storage_bytes > 0) {
2831 const usage = document.createElement("span");
2832 usage.className = "srv-usage";
2833 usage.textContent = metered.storage_cap > 0
2834 ? fmtBytes(metered.storage_bytes) + " of " + fmtBytes(metered.storage_cap) + " stored"
2835 : fmtBytes(metered.storage_bytes) + " stored";
2836 main.append(usage);
2837 }
2838
2839 li.append(star, main);
2840
2841 if (ledger && ledger.entitlement !== "paid") {
2842 const up = document.createElement("button");
2843 up.type = "button";
2844 up.className = "row-upgrade";
2845 up.textContent = "Upgrade";
2846 up.setAttribute("aria-label", "Upgrade " + labelOf(s));
2847 up.addEventListener("click", () => openUpgrade(s));
2848 li.append(up);
2849 }
2850
2851 // Archive: a quiet box glyph that reveals on hover (like the grip). One tap
2852 // sets it aside (reversible via the inline Undo); no confirmation — the archive
2853 // is where the permanent, strongly-worded delete lives.
2854 const arch = document.createElement("button");
2855 arch.type = "button";
2856 arch.className = "row-archive";
2857 arch.setAttribute("aria-label", "Archive " + labelOf(s));
2858 arch.innerHTML = ARCHIVE_SVG;
2859 arch.addEventListener("click", () => archiveSpace(s));
2860 li.append(arch);
2861
2862 // The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) —
2863 // the a11y reorder fallback. Omitted while a search filter is active (you can't
2864 // meaningfully reorder a filtered subset).
2865 if (sortable) {
2866 const handle = document.createElement("button");
2867 handle.type = "button";
2868 handle.className = "drag-handle";
2869 handle.setAttribute("aria-label", "Reorder " + labelOf(s));
2870 handle.innerHTML = GRIP_SVG;
2871 handle.addEventListener("pointerdown", (e) => startDrag(e, li));
2872 handle.addEventListener("keydown", (e) => {
2873 const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0;
2874 if (!dir) return;
2875 e.preventDefault();
2876 const { active, archived } = partitionArchived(vault.obj.servers);
2877 applyServers([...moveInGroup(active, s.url, dir), ...archived]);
2878 focusHandle(s.url); // re-render dropped focus; put it back on the moved row
2879 });
2880 li.append(handle);
2881 }
2882
2883 return li;
2884}
2885
2886function focusHandle(url) {
2887 for (const li of $("spaces").querySelectorAll("li")) {
2888 if (li.dataset.url === url) { li.querySelector(".drag-handle")?.focus(); return; }
2889 }
2890}
2891
2892// ---- drag to reorder ----------------------------------------------------------
2893// Pointer-based (mouse + touch): the grabbed row lifts and follows the pointer
2894// while its siblings slide aside (FLIP). Drag is confined to its own list, so the
2895// Starred and other lists sort independently; the star toggle moves rows between
2896// them. prefers-reduced-motion drops the slide + settle animations.
2897
2898let drag = null;
2899
2900function startDrag(e, li) {
2901 if (e.pointerType === "mouse" && e.button !== 0) return;
2902 e.preventDefault();
2903 const handle = e.currentTarget;
2904 const rect = li.getBoundingClientRect();
2905 drag = { li, ul: li.parentElement, handle, pointerId: e.pointerId, grab: e.clientY - rect.top, moved: false };
2906 handle.setPointerCapture(e.pointerId);
2907 li.classList.add("dragging");
2908 handle.addEventListener("pointermove", onDragMove);
2909 handle.addEventListener("pointerup", endDrag);
2910 handle.addEventListener("pointercancel", endDrag);
2911}
2912
2913function onDragMove(e) {
2914 if (!drag) return;
2915 drag.moved = true;
2916 const { li, ul } = drag;
2917 // Which sibling should we sit above? The first whose midpoint is below us.
2918 let ref = null;
2919 for (const r of ul.children) {
2920 if (r === li || r.tagName !== "LI") continue;
2921 const rc = r.getBoundingClientRect();
2922 if (e.clientY < rc.top + rc.height / 2) { ref = r; break; }
2923 }
2924 const changes = ref ? li.nextElementSibling !== ref : ul.lastElementChild !== li;
2925 if (changes) flipReorder(ul, li, () => (ref ? ul.insertBefore(li, ref) : ul.append(li)));
2926 // Glue the lifted row under the pointer (clear transform first to read its
2927 // natural post-reorder position).
2928 li.style.transition = "none";
2929 li.style.transform = "";
2930 const top = li.getBoundingClientRect().top;
2931 li.style.transform = "translateY(" + (e.clientY - drag.grab - top) + "px)";
2932}
2933
2934function endDrag() {
2935 if (!drag) return;
2936 const { li, handle, pointerId, moved } = drag;
2937 handle.releasePointerCapture?.(pointerId);
2938 handle.removeEventListener("pointermove", onDragMove);
2939 handle.removeEventListener("pointerup", endDrag);
2940 handle.removeEventListener("pointercancel", endDrag);
2941 li.style.transition = reducedMotion() ? "none" : "transform 160ms ease";
2942 li.style.transform = ""; // settle back into the slot the finger left it in
2943 li.classList.remove("dragging");
2944 drag = null;
2945 if (moved) commitOrder();
2946}
2947
2948// flipReorder runs `mutate` (the DOM reorder) and animates every row it displaced
2949// from its old position to its new one — the "slide aside" that makes the reorder
2950// read as physical rather than a jump. The dragged row is excluded (it's glued to
2951// the pointer separately).
2952function flipReorder(ul, exclude, mutate) {
2953 if (reducedMotion()) { mutate(); return; }
2954 const rows = [...ul.children].filter((el) => el.tagName === "LI" && el !== exclude);
2955 const before = new Map(rows.map((el) => [el, el.getBoundingClientRect().top]));
2956 mutate();
2957 for (const el of rows) {
2958 const dy = before.get(el) - el.getBoundingClientRect().top;
2959 if (!dy) continue;
2960 el.style.transition = "none";
2961 el.style.transform = "translateY(" + dy + "px)";
2962 requestAnimationFrame(() => {
2963 el.style.transition = "transform 160ms ease";
2964 el.style.transform = "";
2965 });
2966 }
2967}
2968
2969// commitOrder reads the on-screen order back into the vault and saves — only if it
2970// actually changed (a grab-without-move shouldn't write).
2971function commitOrder() {
2972 const order = [];
2973 for (const ul of $("spaces").querySelectorAll("ul.servers")) {
2974 for (const li of ul.querySelectorAll("li")) order.push(li.dataset.url);
2975 }
2976 const { active, archived } = partitionArchived(vault.obj.servers || []);
2977 const next = orderServers(active, order);
2978 if (next.length === active.length && next.every((s, i) => s.url === active[i].url)) return;
2979 vault.obj.servers = [...next, ...archived];
2980 persistVault();
2981}
2982
2983function reducedMotion() {
2984 return matchMedia("(prefers-reduced-motion: reduce)").matches;
2985}
2986
2987// applyServers swaps in a new servers array (star/keyboard-move returns a fresh
2988// one), re-renders immediately (optimistic), then persists in the background.
2989function applyServers(next) {
2990 vault.obj.servers = next;
2991 showVault();
2992 persistVault();
2993}
2994
2995async function persistVault(status = vaultStatus) {
2996 status("Saving…", "busy");
2997 try {
2998 await saveVault();
2999 status("Saved.", "ok");
3000 } catch (e) {
3001 status(e.message || "Couldn't save your changes.", "error");
3002 }
3003}
3004
3005// ---- upgrade a space ----------------------------------------------------------
3006
3007function upgradeStatus(msg, kind) {
3008 const el = $("upgrade-status");
3009 el.textContent = msg || "";
3010 el.className = "status" + (kind ? " " + kind : "");
3011}
3012
3013// tierName maps a ledger tier key to its member-facing name; spaces upgraded
3014// under a tier that's since left the price book still get a badge.
3015function tierName(key) {
3016 const t = billingTiers.find((x) => x.key === key);
3017 return (t && t.name) || "Supporter";
3018}
3019
3020// The picker's band + ≈ state. The band resets to base (or a deep link) per
3021// open; the currency override is session-only — nothing about the buyer is
3022// stored, matching the honor design (docs/pricing-bands.md).
3023let selectedBand = "base";
3024let approxCurrency = ""; // "" = follow the browser locale; "EUR" = hide ≈
3025let fxSnapshot = null; // {date, rates} once /api/fx answers; null before
3026
3027// openUpgrade shows the plan picker for one space. The payment itself is
3028// Stripe's hosted page; this screen only chooses the plan and hands off.
3029// fromLink keeps the deep link's &tier= highlight; a manual open drops it.
3030function openUpgrade(s, fromLink) {
3031 if (!fromLink) tierLink = "";
3032 hideVault();
3033 $("upgrade").classList.remove("hidden");
3034 $("upgrade-host").textContent = hostOf(s.url);
3035 upgradeStatus("");
3036 if (bandLink) {
3037 selectedBand = bandLink;
3038 bandLink = "";
3039 history.replaceState(null, "", location.pathname + location.search);
3040 }
3041 // The ≈ rates: one fetch per page life, rendered in when they land. The
3042 // picker is complete without them — an absent estimate beats a wrong one.
3043 if (!fxSnapshot) {
3044 fxSnapshot = { date: "", rates: null };
3045 apiJSON("/api/fx")
3046 .then((b) => {
3047 fxSnapshot = { date: b.date || "", rates: b.rates || null };
3048 if (!$("upgrade").classList.contains("hidden")) renderUpgrade(s);
3049 })
3050 .catch(() => { /* no rates — the EUR stickers stand alone */ });
3051 }
3052 renderUpgrade(s);
3053}
3054
3055// renderUpgrade paints the whole picker for the selected band: the band row
3056// (only when the price book actually offers bands), each tier at that band's
3057// price with its ≈ local figure, the honor line, and the +15% review promise.
3058function renderUpgrade(s) {
3059 const offered = bandsOffered(billingTiers);
3060 const banded = offered.length > 1;
3061 if (!offered.includes(selectedBand)) selectedBand = "base";
3062
3063 const row = $("upgrade-bands");
3064 row.textContent = "";
3065 row.classList.toggle("hidden", !banded);
3066 if (banded) {
3067 for (const key of offered) {
3068 const chip = document.createElement("button");
3069 chip.type = "button";
3070 chip.className = "band-chip" + (key === selectedBand ? " selected" : "");
3071 chip.setAttribute("aria-pressed", key === selectedBand ? "true" : "false");
3072 chip.textContent = bandName(key);
3073 chip.addEventListener("click", () => {
3074 selectedBand = key;
3075 renderUpgrade(s);
3076 });
3077 row.append(chip);
3078 }
3079 }
3080 const note = $("upgrade-band-note");
3081 note.textContent = banded
3082 ? BANDS.find((b) => b.key === selectedBand).line + " Pick the price that's right for you — no questions asked."
3083 : "";
3084
3085 const currency = approxCurrency || regionCurrency(navigator.language);
3086 const rates = fxSnapshot && fxSnapshot.rates;
3087 const box = $("upgrade-tiers");
3088 box.textContent = "";
3089 // The listing said this space isn't paid, but its region advertised no
3090 // tiers to sell (billing not configured there, or momentarily unreachable)
3091 // — say so plainly rather than leaving a silent gap where the plan
3092 // buttons belong (openUpgrade only gets here once ownership + free-tier
3093 // are already confirmed, so the sheet itself is warranted).
3094 if (!billingTiers.length) {
3095 upgradeStatus("Upgrading isn't available for this space right now.", "error");
3096 $("upgrade-fx").classList.add("hidden");
3097 $("upgrade-cap").textContent = "";
3098 return;
3099 }
3100 let anyApprox = false;
3101 for (const t of billingTiers) {
3102 const view = tierForBand(t, selectedBand);
3103 const btn = document.createElement("button");
3104 btn.type = "button";
3105 btn.className = "tier-btn";
3106 const name = document.createElement("span");
3107 name.className = "tier-name";
3108 name.textContent = t.name;
3109 const price = document.createElement("span");
3110 price.className = "tier-price";
3111 // Business stickers are ex-VAT wherever they appear (docs/pricing-bands.md § VAT).
3112 price.textContent = view.label + (t.business ? " excl. VAT" : "");
3113 btn.append(name, price);
3114 const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language);
3115 if (approx) {
3116 anyApprox = true;
3117 const a = document.createElement("span");
3118 a.className = "tier-approx";
3119 a.textContent = approx;
3120 btn.append(a);
3121 }
3122 // A tier without the selected band sells at its standard price — said
3123 // plainly, never implied to be reduced.
3124 if (banded && !view.offered) {
3125 const std = document.createElement("span");
3126 std.className = "tier-band-std";
3127 std.textContent = "standard price";
3128 btn.append(std);
3129 }
3130 // The deep link's chosen tier (the setup conversation's plan chip):
3131 // highlighted, not auto-bought — checkout stays the user's tap.
3132 if (tierLink && tierLink === t.key) {
3133 btn.classList.add("selected");
3134 btn.setAttribute("aria-pressed", "true");
3135 }
3136 btn.addEventListener("click", () => startUpgrade(hostOf(s.url), t.key, view.band, box));
3137 box.append(btn);
3138 }
3139
3140 // The ≈ attribution + currency override. Shown whenever rates exist and a
3141 // display currency is in play — including "EUR" (≈ hidden), or picking EUR
3142 // would make the override itself vanish with no way back. Euro-area locales
3143 // with no override see none of this: they're billed in their own currency.
3144 const fxNote = $("upgrade-fx");
3145 fxNote.textContent = "";
3146 const showFx = !!rates && !!currency;
3147 fxNote.classList.toggle("hidden", !showFx);
3148 if (showFx) {
3149 const span = document.createElement("span");
3150 span.textContent = anyApprox ? "Billed in euros — ≈ is an estimate in " : "Billed in euros — show ≈ in ";
3151 const sel = document.createElement("select");
3152 sel.className = "fx-currency";
3153 sel.setAttribute("aria-label", "Currency for the approximate figures");
3154 for (const c of [...Object.keys(rates), "EUR"].sort()) {
3155 const opt = document.createElement("option");
3156 opt.value = c;
3157 opt.textContent = c;
3158 if (c === currency) opt.selected = true;
3159 sel.append(opt);
3160 }
3161 sel.addEventListener("change", () => {
3162 approxCurrency = sel.value;
3163 renderUpgrade(s);
3164 });
3165 const tail = document.createElement("span");
3166 tail.textContent = anyApprox && fxSnapshot.date ? " at the ECB rate of " + fxSnapshot.date + "." : ".";
3167 fxNote.append(span, sel, tail);
3168 }
3169
3170 // The +15% annual-review promise, in money (never a bare percent): the
3171 // first tier at the selected band is the concrete example.
3172 const cap = $("upgrade-cap");
3173 const first = billingTiers[0] && tierForBand(billingTiers[0], selectedBand);
3174 const example = first && first.eur ? capNote(first.eur, navigator.language) : "";
3175 cap.textContent = "Every price is reviewed once a year" +
3176 (example ? " — " + billingTiers[0].name + ": " + example : "") +
3177 ". Rises above 15% only ever happen with your explicit yes; decreases happen by themselves.";
3178}
3179
3180function closeUpgrade() {
3181 $("upgrade").classList.add("hidden");
3182 showVault();
3183}
3184
3185// startUpgrade begins the checkout round trip for the chosen plan + band:
3186// home mints a Stripe Checkout link (ownership enforced by the provisioner's
3187// ledger) and we follow it in the same tab — Stripe brings you back here with
3188// ?upgraded=<host> when it's done, and Cancel comes straight back too.
3189async function startUpgrade(host, tier, band, box) {
3190 for (const b of box.querySelectorAll("button")) b.disabled = true;
3191 upgradeStatus("Opening checkout…", "busy");
3192 try {
3193 const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/checkout", {
3194 method: "POST",
3195 headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
3196 body: JSON.stringify({ tier, band }),
3197 });
3198 const body = await res.json().catch(() => ({}));
3199 if (!res.ok) throw new Error(body.message || body.error || `Couldn't start the upgrade (HTTP ${res.status}).`);
3200 if (!body.url) throw new Error("The checkout link didn't come back — try again.");
3201 location.href = body.url;
3202 } catch (e) {
3203 upgradeStatus(e.message || "Couldn't start the upgrade.", "error");
3204 for (const b of box.querySelectorAll("button")) b.disabled = false;
3205 }
3206}
3207
3208// ---- keep (claim) a demo space --------------------------------------------------
3209
3210function claimStatus(msg, kind) {
3211 const el = $("claim-status");
3212 el.textContent = msg || "";
3213 el.className = "status" + (kind ? " " + kind : "");
3214}
3215
3216// consumeClaimLink honours a #claim=<host> deep link (the demo banner's "Keep
3217// this space") once signed in: opens the claim screen for that host. Cleared
3218// either way, so a stale fragment can't re-fire the screen on later renders.
3219function consumeClaimLink() {
3220 if (!claimLinkHost || !vault) return;
3221 const host = claimLinkHost;
3222 claimLinkHost = "";
3223 history.replaceState(null, "", location.pathname + location.search);
3224 openClaim(host);
3225}
3226
3227// openClaim shows the one-confirm claim screen. No pre-checks here — the
3228// provisioner's answers (quota, not-a-demo, expired) are member-facing and
3229// shown verbatim, so asking twice would only add a failure mode.
3230function openClaim(host) {
3231 hideVault();
3232 $("claim").classList.remove("hidden");
3233 $("claim-host").textContent = host;
3234 claimStatus("");
3235 const go = $("claim-go");
3236 go.hidden = false;
3237 go.disabled = false;
3238 go.onclick = () => doClaim(host, go);
3239 $("claim-open").classList.add("hidden");
3240}
3241
3242function closeClaim() {
3243 $("claim").classList.add("hidden");
3244 showVault();
3245}
3246
3247// doClaim is the real call: home relays to the provisioner (ownership
3248// transfer, quota, expiry guard), then the space folds into this account's
3249// encrypted list exactly like a create — sign-in always finds the way back.
3250async function doClaim(host, btn) {
3251 btn.disabled = true;
3252 claimStatus("Claiming " + host + "…", "busy");
3253 try {
3254 const res = await fetch("/api/spaces/claim", {
3255 method: "POST",
3256 headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
3257 body: JSON.stringify({ host }),
3258 });
3259 const body = await res.json().catch(() => ({}));
3260 if (!res.ok) throw new Error(body.message || body.error || `Couldn't claim this space (HTTP ${res.status}).`);
3261 const url = body.url || "https://" + host;
3262 vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url);
3263 vault.obj.servers.push({ url, name: host });
3264 vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins
3265 try {
3266 await saveVault();
3267 } catch {
3268 // The claim stuck even if the vault save raced; the entry lands on the
3269 // next successful save.
3270 }
3271 provisioned = null; // the ledger now lists this space as this account's
3272 claimStatus("This space is yours now — the countdown is off, and it's saved to your list.", "ok");
3273 btn.hidden = true;
3274 const open = $("claim-open");
3275 open.href = url;
3276 open.classList.remove("hidden");
3277 } catch (e) {
3278 claimStatus(e.message || "Couldn't claim this space.", "error");
3279 btn.disabled = false;
3280 }
3281}
3282
3283// openPortal hands off to Stripe's Billing Portal for one space — cancel,
3284// change card, invoices. Same-tab round trip; the portal's Return link comes
3285// back here.
3286async function openPortal(host, btn) {
3287 btn.disabled = true;
3288 vaultStatus("Opening your billing page…", "busy");
3289 try {
3290 const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/portal", {
3291 method: "POST",
3292 headers: { Authorization: "Bearer " + state.token },
3293 });
3294 const body = await res.json().catch(() => ({}));
3295 if (!res.ok) throw new Error(body.message || body.error || `Couldn't open the billing page (HTTP ${res.status}).`);
3296 if (!body.url) throw new Error("The billing link didn't come back — try again.");
3297 location.href = body.url;
3298 } catch (e) {
3299 vaultStatus(e.message || "Couldn't open the billing page.", "error");
3300 btn.disabled = false;
3301 }
3302}
3303
3304// ---- create a new space -------------------------------------------------------
3305
3306// requestSpace is the real create call, fired by the fader the moment it snaps
3307// to eleven: POST /api/spaces (link-token auth) — it returns only once the
3308// server has confirmed the space is live and reachable, so the fader's build
3309// phase IS the wait — then fold the new space into the vault so sign-in always
3310// finds the way back. Throws a member-facing Error for the fader's error path.
3311async function requestSpace() {
3312 if (!vault) throw new Error("Sign in first — your new space is saved into your account.");
3313 // The proof-of-work gate, when this home server runs one: fetch a challenge
3314 // and grind it out right here, during the fader's build phase — the CPU
3315 // burn IS the "Hold on…" moment. bits 0 = no gate, nothing to solve.
3316 const proof = await solveCreateChallenge();
3317 // No name: a space is born nameless and Eleven asks inside it (the setup
3318 // conversation's "group" step), where the answer is a reply in a
3319 // conversation instead of a form field on the way in. Home learns the name
3320 // when the space's key is added to this account (the enrol payload's entry).
3321 const body = { ...(proof || {}) };
3322 const res = await fetch("/api/spaces", {
3323 method: "POST",
3324 headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
3325 body: JSON.stringify(body),
3326 });
3327 const reply = await res.json().catch(() => ({}));
3328 if (!res.ok) {
3329 // Structured errors carry a member-facing message (quota, at-capacity).
3330 // The code rides along so the fader's error path can tell "paying would
3331 // fix this" (quota, pool pressure) from a plain failure (#1374).
3332 const err = new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`);
3333 err.code = reply.error || "";
3334 throw err;
3335 }
3336 const url = reply.url;
3337 const host = new URL(url).host;
3338 vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url);
3339 vault.obj.servers.push({ url, name: host });
3340 vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins
3341 try {
3342 await saveVault();
3343 } catch {
3344 // The space exists even if the vault save raced — still confirm it; the
3345 // entry lands on the next successful save.
3346 }
3347 return { host, setupLink: reply.setup_link };
3348}
3349
3350// requestPaidSpace is the paid branch behind the fader: mint the Checkout for
3351// the chosen plan and hand the tab to Stripe. The space is only created on
3352// return (claimPaidCreate); a localStorage marker means even a closed tab can
3353// finish next visit. The returned promise never resolves — navigation is the
3354// resolution.
3355async function requestPaidSpace() {
3356 if (!vault) throw new Error("Sign in first — your new space is saved into your account.");
3357 const proof = await solveCreateChallenge();
3358 const res = await fetch("/api/spaces/checkout-create", {
3359 method: "POST",
3360 headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
3361 body: JSON.stringify({ ...(proof || {}), tier: planChoice.tier }),
3362 });
3363 const reply = await res.json().catch(() => ({}));
3364 if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't start the checkout (HTTP ${res.status}).`);
3365 if (!reply.url) throw new Error("The checkout link didn't come back — try again.");
3366 if (reply.session_id) {
3367 rememberPendingCreate({ session: reply.session_id, at: Date.now() });
3368 }
3369 location.href = reply.url;
3370 return new Promise(() => {});
3371}
3372
3373// ---- the pay-first marker + claim ---------------------------------------------
3374
3375// The localStorage marker for a checkout in flight: {session, at}. It
3376// exists so a tab closed on Stripe's page still finishes the create on the
3377// next visit here — the claim is idempotent, so over-firing is safe.
3378const PENDING_CREATE_KEY = "lchat-pending-create";
3379const claimedSessions = new Set(); // auto-claim once per page life; Try again drives retries
3380
3381function rememberPendingCreate(m) {
3382 try { localStorage.setItem(PENDING_CREATE_KEY, JSON.stringify(m)); } catch { /* private mode — the redirect still carries the session */ }
3383}
3384function pendingCreateMarker() {
3385 try { return JSON.parse(localStorage.getItem(PENDING_CREATE_KEY) || "null"); } catch { return null; }
3386}
3387function clearPendingCreate() {
3388 try { localStorage.removeItem(PENDING_CREATE_KEY); } catch { /* nothing to clear */ }
3389}
3390
3391// claimPaidCreate finishes a pay-first create: the provisioner verifies the
3392// session with Stripe and provisions on the spot, so the create screen's
3393// building beat is the honest wait. 402/404 mean the checkout never completed
3394// (cancelled, expired, or another account's) — drop the marker quietly and
3395// return to the list; the pending allocation prunes itself server-side.
3396async function claimPaidCreate(session, region) {
3397 hideVault();
3398 $("create-plan").classList.add("hidden");
3399 $("create").classList.remove("hidden");
3400 $("create-back").classList.add("hidden");
3401 $("create-pitch").classList.add("hidden");
3402 $("new-space-result").classList.add("hidden");
3403 $("create-building").classList.remove("hidden");
3404 try {
3405 const res = await fetch("/api/spaces/claim-create", {
3406 method: "POST",
3407 headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
3408 body: JSON.stringify({ session_id: session, region: region || "" }),
3409 });
3410 const reply = await res.json().catch(() => ({}));
3411 if (res.status === 402 || res.status === 404) {
3412 clearPendingCreate();
3413 $("create-building").classList.add("hidden");
3414 $("create").classList.add("hidden");
3415 showVault();
3416 return;
3417 }
3418 if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't finish the setup (HTTP ${res.status}).`);
3419 const url = reply.url;
3420 const host = new URL(url).host;
3421 vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== url);
3422 vault.obj.servers.push({ url, name: host });
3423 vault.obj.removed = clearTombstone(vault.obj.removed, url);
3424 try { await saveVault(); } catch { /* the space exists; the entry lands on the next save */ }
3425 clearPendingCreate();
3426 $("create-building").classList.add("hidden");
3427 showNewSpaceResult(host, reply.setup_link);
3428 } catch (e) {
3429 // Retriable (the provisioner said the payment is safe): keep the marker
3430 // and offer Try again right here.
3431 $("create-building").classList.add("hidden");
3432 const body = $("new-space-body");
3433 body.textContent = "";
3434 const msg = document.createElement("p");
3435 msg.className = "create-sub";
3436 msg.textContent = e.message || "The space couldn't be set up just now — your payment is safe.";
3437 const retry = document.createElement("button");
3438 retry.type = "button";
3439 retry.className = "secondary";
3440 retry.textContent = "Try again";
3441 retry.addEventListener("click", () => claimPaidCreate(session, region));
3442 body.append(msg, retry);
3443 $("create-back").classList.add("hidden");
3444 $("new-space-result").classList.remove("hidden");
3445 }
3446}
3447
3448// solveCreateChallenge grinds the home server's hashcash challenge: find a
3449// nonce where SHA-256(challenge + ":" + nonce) clears the difficulty. Async
3450// digests yield to the event loop, so the fader's build animation keeps
3451// breathing while we work. null when the server runs no gate.
3452async function solveCreateChallenge() {
3453 const res = await fetch("/api/spaces/challenge", {
3454 method: "POST",
3455 headers: { Authorization: "Bearer " + state.token },
3456 });
3457 const c = await res.json().catch(() => ({}));
3458 if (!res.ok || !c.bits) return null;
3459 const enc = new TextEncoder();
3460 for (let n = 0; ; n++) {
3461 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(c.challenge + ":" + n)));
3462 if (leadingZeroBits(digest) >= c.bits) return { challenge: c.challenge, nonce: String(n) };
3463 }
3464}
3465
3466// tryDemo: the no-account path (POST /api/demo/challenge → grind → POST
3467// /api/demo). The hashcash burn IS the wait, same trade as creation; every
3468// outcome lands inline in #status (never a toast) — the server's messages are
3469// member-facing (cooldown, demos-busy) and shown verbatim. On success we go
3470// straight into the fresh space's setup link, same-tab like the create flow.
3471async function tryDemo() {
3472 const btn = $("try-demo");
3473 btn.disabled = true;
3474 setStatus("Preparing your demo space — a few seconds of math keeps the bots away…", "busy");
3475 try {
3476 const res = await fetch("/api/demo/challenge", { method: "POST" });
3477 const c = await res.json().catch(() => ({}));
3478 if (!res.ok) throw new Error(c.message || "Demos aren't available right now.");
3479 const enc = new TextEncoder();
3480 let nonce = null;
3481 for (let n = 0; ; n++) {
3482 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(c.challenge + ":" + n)));
3483 if (leadingZeroBits(digest) >= c.bits) { nonce = String(n); break; }
3484 }
3485 setStatus("Building your demo space…", "busy");
3486 const create = await fetch("/api/demo", {
3487 method: "POST",
3488 headers: { "Content-Type": "application/json" },
3489 body: JSON.stringify({ challenge: c.challenge, nonce }),
3490 });
3491 const body = await create.json().catch(() => ({}));
3492 if (!create.ok) throw new Error(body.message || "Couldn't create a demo space right now.");
3493 location.href = body.setup_link;
3494 } catch (err) {
3495 setStatus(err.message, "error");
3496 btn.disabled = false;
3497 }
3498}
3499$("try-demo").addEventListener("click", tryDemo);
3500
3501function leadingZeroBits(bytes) {
3502 let bits = 0;
3503 for (const b of bytes) {
3504 if (b === 0) { bits += 8; continue; }
3505 for (let mask = 128; mask; mask >>= 1) {
3506 if (b & mask) return bits;
3507 bits++;
3508 }
3509 }
3510 return bits;
3511}
3512
3513// A checkmark for the single-serve confirmation (static, in-code SVG).
3514const CHECK_SVG =
3515 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" ' +
3516 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>';
3517
3518// showNewSpaceResult fills the confirmation pane inside the create screen: a
3519// sealed check, the new space's name, and the one-time setup link (the admin
3520// flow — never the bare instance URL). The Done button is static in the markup.
3521function showNewSpaceResult(host, setupLink) {
3522 const body = $("new-space-body");
3523 body.textContent = "";
3524
3525 const check = document.createElement("div");
3526 check.className = "ok-check";
3527 check.innerHTML = CHECK_SVG;
3528
3529 const title = document.createElement("p");
3530 title.className = "created-name";
3531 title.textContent = "Your space is ready";
3532
3533 const hostLine = document.createElement("p");
3534 hostLine.className = "sub";
3535 hostLine.textContent = host;
3536 body.append(check, title, hostLine);
3537
3538 if (setupLink) {
3539 const a = document.createElement("a");
3540 a.className = "btn-link";
3541 // ?src=home tells the space's setup flow this person already holds an
3542 // Eleven sign-in, so its key step says "add to your passkey" instead of
3543 // pitching passkeys from scratch. Query param, not fragment: the secret
3544 // stays alone in the fragment. (Query never carries anything sensitive.)
3545 a.href = setupLink.replace("/join/setup#", "/join/setup?src=home#");
3546 // Same tab, not a new one: setting up a space is where you're headed next,
3547 // and you'll come back here (to your list) once it's done — no reason to
3548 // strand this page behind a second tab.
3549 a.textContent = "Set up your space →";
3550 const note = document.createElement("p");
3551 note.className = "sub";
3552 note.style.marginTop = "12px";
3553 note.textContent = "This one-time link is only for you.";
3554 body.append(a, note);
3555 } else {
3556 // No setup link (mint failed) — deliberately not the bare instance URL, which
3557 // lands on a signed-out card rather than the admin flow.
3558 const note = document.createElement("p");
3559 note.className = "sub";
3560 note.textContent = "The setup link couldn't be created just now — head back and create again.";
3561 body.append(note);
3562 }
3563
3564 $("create-back").classList.add("hidden"); // the confirmation's Done is the way out
3565 $("new-space-result").classList.remove("hidden");
3566}
3567
3568// ---- create-screen navigation -------------------------------------------------
3569
3570// The fader mounts once (it builds its scale + meter into the DOM) and is
3571// reset/started per visit; stop() parks its loop and clears the stage canvas.
3572let fader = null;
3573function ensureFader() {
3574 if (fader) return fader;
3575 fader = mountFader({
3576 fader: $("fader"),
3577 meter: $("fader-meter"),
3578 shakeEl: document.querySelector("main.card"),
3579 canvas: $("stage-dust"),
3580 pitch: $("create-pitch"),
3581 building: $("create-building"),
3582 // The back link hides the moment the snap fires — the create is in flight
3583 // and can't be cancelled; error/ready each restore a way out.
3584 onEleven: () => {
3585 $("create-back").classList.add("hidden");
3586 return planChoice && planChoice.tier ? requestPaidSpace() : requestSpace();
3587 },
3588 onReady: (r) => showNewSpaceResult(r.host, r.setupLink),
3589 onError: (err) => {
3590 // The two errors paying genuinely fixes — the free quota and the free
3591 // pool's budget gate — reopen the plan screen with the paid tiers
3592 // showing (#1374). Everything else stays a plain inline error.
3593 const code = (err && err.code) || "";
3594 if ((code === "quota_exceeded" || code === "free_pool_full") && billingTiers.length) {
3595 openPlanBounce((err && err.message) || "");
3596 return;
3597 }
3598 $("create-back").classList.remove("hidden");
3599 spaceStatus((err && err.message) || String(err), "error");
3600 },
3601 });
3602 return fader;
3603}
3604
3605// The plan behind the slider — consumed by the fader's onEleven to pick the
3606// free create or the Stripe handoff. tier "" = free, which is every create
3607// that didn't bounce off a paid gate.
3608let planChoice = null;
3609let planTier = "free";
3610// planPaidRequired: the create bounced off a gate paying genuinely fixes
3611// (the free quota, or the pool under budget pressure) — the plan screen
3612// re-opens showing the paid tiers. The HAPPY path never shows them: every
3613// space is born free, the slider creates it immediately, and "who is this
3614// space for?" is Eleven's question inside the space (#1374).
3615let planPaidRequired = false;
3616
3617// openCreate opens the create: the slider, and nothing before it. Nothing is
3618// asked on the way in — not a name (Eleven asks inside the space), not a plan
3619// (every space is born free, #1374) — so the fastest path from "I want a
3620// space" to having one is a single gesture. The plan screen exists only for
3621// the bounce (openPlanBounce).
3622function openCreate() {
3623 clearUndo();
3624 planChoice = null;
3625 planTier = "free";
3626 planPaidRequired = false;
3627 planStatus("");
3628 // The ledger read warms the price book — the paid-required error path needs
3629 // it in hand (renderPlanTiers fills in when it answers).
3630 if (spacesEnabled && provisioned === null && state.token) refreshProvisioned();
3631 hideVault();
3632 openCreateFader();
3633}
3634
3635function closePlan() {
3636 $("create-plan").classList.add("hidden");
3637 showVault();
3638}
3639
3640// openPlanBounce shows the plan screen — the ONLY thing that does. The create
3641// hit a gate paying genuinely fixes (the free quota, the pool under budget
3642// pressure), so the paid tiers are the way through, with the gate's own
3643// message above them.
3644function openPlanBounce(message) {
3645 fader?.stop();
3646 $("create").classList.add("hidden");
3647 planPaidRequired = true;
3648 planTier = billingTiers[0].key;
3649 renderPlanTiers();
3650 $("create-plan").classList.remove("hidden");
3651 planStatus(message, "error");
3652}
3653
3654function planStatus(msg, kind) {
3655 const el = $("plan-status");
3656 el.textContent = msg || "";
3657 el.className = "status" + (kind ? " " + kind : "");
3658}
3659
3660// renderPlanTiers paints the who-pays picker from the provisioner's price
3661// book — but ONLY in paid-required mode (#1374): the happy path asks nothing
3662// at all, and the plan question lives in Eleven's setup conversation. Here the
3663// create bounced off the quota or the pool gate, so the paid tiers (no Free
3664// card — free is exactly what bounced) are the way through, at base price
3665// with the ≈ local figure the upgrade picker also shows.
3666function renderPlanTiers() {
3667 const wrap = $("plan-tiers-wrap");
3668 const showing = planPaidRequired && billingTiers.length;
3669 wrap.classList.toggle("hidden", !showing);
3670 if (!showing) return;
3671 $("plan-tiers-lead").textContent = "Pick a plan to create it now:";
3672 // The ≈ rates ride the same one-fetch-per-page-life snapshot as the
3673 // upgrade picker; render now, again when they land.
3674 if (!fxSnapshot) {
3675 fxSnapshot = { date: "", rates: null };
3676 apiJSON("/api/fx")
3677 .then((b) => {
3678 fxSnapshot = { date: b.date || "", rates: b.rates || null };
3679 if (!$("create-plan").classList.contains("hidden")) renderPlanTiers();
3680 })
3681 .catch(() => { /* no rates — the EUR stickers stand alone */ });
3682 }
3683 const currency = approxCurrency || regionCurrency(navigator.language);
3684 const rates = fxSnapshot && fxSnapshot.rates;
3685 const box = $("plan-tiers");
3686 box.textContent = "";
3687 const card = (key, title, priceLine, subLine, approx) => {
3688 const btn = document.createElement("button");
3689 btn.type = "button";
3690 btn.className = "tier-btn wrap" + (key === planTier ? " selected" : "");
3691 btn.setAttribute("aria-pressed", key === planTier ? "true" : "false");
3692 const name = document.createElement("span");
3693 name.className = "tier-name";
3694 name.textContent = title;
3695 const price = document.createElement("span");
3696 price.className = "tier-price";
3697 price.textContent = priceLine;
3698 btn.append(name, price);
3699 if (approx) {
3700 const a = document.createElement("span");
3701 a.className = "tier-approx";
3702 a.textContent = approx;
3703 btn.append(a);
3704 }
3705 if (subLine) {
3706 const sub = document.createElement("span");
3707 sub.className = "tier-sub";
3708 sub.textContent = subLine;
3709 btn.append(sub);
3710 }
3711 btn.addEventListener("click", () => {
3712 planTier = key;
3713 renderPlanTiers();
3714 });
3715 box.append(btn);
3716 };
3717 for (const t of billingTiers) {
3718 const view = tierForBand(t, "base");
3719 const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language);
3720 card(t.key, t.name, view.label + (t.business ? " excl. VAT" : ""), "", approx);
3721 }
3722 const fxNote = $("plan-fx");
3723 fxNote.classList.toggle("hidden", !(rates && currency && currency !== "EUR"));
3724 fxNote.textContent = rates && fxSnapshot.date ? "Billed in euros — ≈ at the ECB rate of " + fxSnapshot.date + "." : "";
3725}
3726
3727// planContinue commits the bounce's chosen plan and returns to the slider —
3728// the same commit as the free path, with a Stripe handoff behind it.
3729function planContinue() {
3730 planChoice = { tier: planTier === "free" ? "" : planTier };
3731 $("create-plan").classList.add("hidden");
3732 openCreateFader();
3733}
3734
3735// openCreateFader opens step 2, the goes-to-eleven slider — the commit for
3736// both branches; only the promise behind it differs.
3737function openCreateFader() {
3738 $("new-space-result").classList.add("hidden");
3739 $("create-back").classList.remove("hidden");
3740 $("create-sub-line").textContent = planChoice && planChoice.tier
3741 ? "Slide, and we'll take you to the payment — your space is created the moment it's paid."
3742 : "Your new space will be created immediately.";
3743 spaceStatus("");
3744 $("create").classList.remove("hidden");
3745 const f = ensureFader();
3746 f.reset();
3747 f.start();
3748}
3749
3750function closeCreate() {
3751 fader?.stop();
3752 $("create").classList.add("hidden");
3753 showVault(); // reveals the list again, now including any newly created space
3754}
3755
3756// ---- archive: list, unarchive, permanent delete --------------------------------
3757
3758// archiving is the entry currently in the stern delete step of the archive.
3759let archiving = null;
3760// The last-admin guard: before letting someone drop the only saved copy of a
3761// space's ONLY admin key, ask the space itself (checkSoleAdmin). While
3762// sternSoleAdmin is set, "Delete your access" stays off; sternDestroyable
3763// (provisioner ownership confirmed) picks which way the note points instead.
3764let sternSoleAdmin = false;
3765let sternDestroyable = false;
3766// sternShell: the provisioner's ledger says this owned space was never set up
3767// and holds only its pre-seeded admin — an unusable shell nobody can enter.
3768// Deleting access to one strands a quota slot forever, so destroy is promoted
3769// to the only action. Server truth (the meter sweep records claimed-ness), so
3770// it works exactly where checkSoleAdmin can't: entries with no credentials.
3771let sternShell = false;
3772// sternPaid: the ledger says this space has a live subscription — destroying
3773// it also cancels that subscription (the provisioner enforces it), and the
3774// ceremony copy must say so.
3775let sternPaid = false;
3776
3777// openArchive shows the archive screen (the archived-spaces list).
3778function openArchive() {
3779 clearUndo();
3780 archiving = null;
3781 hideVault();
3782 $("archive-stern").classList.add("hidden");
3783 $("archive-checking").classList.add("hidden");
3784 $("archive-main").classList.remove("hidden");
3785 $("archive").classList.remove("hidden");
3786 archiveStatus("");
3787 renderArchiveList();
3788}
3789
3790function closeArchive() {
3791 archiving = null;
3792 $("archive").classList.add("hidden");
3793 showVault();
3794}
3795
3796// closeArchiveStern steps back from the delete step to the archive list.
3797function closeArchiveStern() {
3798 archiving = null;
3799 $("archive-stern").classList.add("hidden");
3800 $("archive-checking").classList.add("hidden");
3801 $("archive-main").classList.remove("hidden");
3802 archiveStatus("");
3803}
3804
3805function renderArchiveList() {
3806 const box = $("archive-list");
3807 box.textContent = "";
3808 const { archived } = partitionArchived(vault.obj.servers || []);
3809 if (!archived.length) {
3810 box.append(emptyLine("Nothing archived."));
3811 return;
3812 }
3813 const ul = document.createElement("ul");
3814 ul.className = "servers archived";
3815 for (const s of archived) ul.append(archiveRow(s));
3816 box.append(ul);
3817}
3818
3819function archiveRow(s) {
3820 const li = document.createElement("li");
3821 li.dataset.url = s.url;
3822
3823 const main = document.createElement("div");
3824 main.className = "srv-main";
3825 const name = document.createElement("span"); // plain text — archived spaces stay quiet
3826 name.className = "name";
3827 name.textContent = labelOf(s);
3828 if (s.name) name.title = hostOf(s.url);
3829 main.append(name);
3830
3831 const unarch = document.createElement("button");
3832 unarch.type = "button";
3833 unarch.className = "pill";
3834 unarch.textContent = "Unarchive";
3835 unarch.addEventListener("click", () => unarchiveSpace(s));
3836
3837 const del = document.createElement("button");
3838 del.type = "button";
3839 del.className = "pill danger-ink";
3840 del.textContent = "Delete…";
3841 del.addEventListener("click", () => openArchiveDelete(s));
3842
3843 li.append(main, unarch, del);
3844 return li;
3845}
3846
3847// unarchiveSpace moves the entry back to the active list.
3848function unarchiveSpace(s) {
3849 vault.obj.servers = setArchived(vault.obj.servers, s.url, false);
3850 if (!partitionArchived(vault.obj.servers).archived.length) {
3851 closeArchive(); // archive is empty now — the space is back on the list
3852 persistVault();
3853 return;
3854 }
3855 renderArchiveList();
3856 persistVault(archiveStatus);
3857}
3858
3859// The stern step assembles BEFORE it shows: both checks (ownership → destroy
3860// zone, last-admin → hold) run first, so pieces never pop into a visible panel.
3861// A fast answer shows no loader at all; past STERN_LOADER_DELAY_MS the spinner
3862// appears, and at STERN_CHECK_CAP_MS we reveal with whatever has settled — a
3863// straggler still applies live (rare, and correctness beats stillness).
3864const STERN_LOADER_DELAY_MS = 150;
3865const STERN_CHECK_CAP_MS = 2500;
3866const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3867
3868// openArchiveDelete opens the stern permanent-delete step for one archived space.
3869// "Delete your access" (drop the saved entry) is offered unless the space reports
3870// this is its ONLY admin key (checkSoleAdmin); for a space THIS account created,
3871// the deeper "destroy for everyone" is revealed too (ownership checked against
3872// the provisioner ledger; the server re-enforces it on delete).
3873async function openArchiveDelete(s) {
3874 archiving = s;
3875 sternSoleAdmin = false;
3876 sternDestroyable = false;
3877 sternShell = false;
3878 sternPaid = false;
3879 const host = hostOf(s.url);
3880 $("stern-name").textContent = labelOf(s);
3881 $("stern-host").textContent = host;
3882 $("delete-arm").value = "";
3883 $("delete-arm").placeholder = host;
3884 $("delete-access").disabled = true;
3885 $("sole-admin-note").classList.add("hidden");
3886 $("destroy-zone").classList.add("hidden");
3887 $("destroy-forever").disabled = true;
3888 $("archive-main").classList.add("hidden");
3889 $("archive-stern").classList.add("hidden");
3890 $("archive-checking").classList.add("hidden");
3891 archiveStatus("");
3892
3893 const checks = Promise.allSettled([checkOwnership(s, host), checkSoleAdmin(s)]);
3894 const fast = await Promise.race([checks.then(() => true), sleep(STERN_LOADER_DELAY_MS).then(() => false)]);
3895 if (archiving !== s) return; // backed out mid-check
3896 if (!fast) {
3897 $("archive-checking").classList.remove("hidden");
3898 await Promise.race([checks, sleep(STERN_CHECK_CAP_MS)]);
3899 if (archiving !== s) return;
3900 $("archive-checking").classList.add("hidden");
3901 }
3902 updateSternHold();
3903 $("archive-stern").classList.remove("hidden");
3904}
3905
3906// checkOwnership learns whether THIS account created the space (the provisioner
3907// ledger), which puts "destroy for everyone" on the table. Rendering happens in
3908// updateSternHold — this only sets the flag, so it composes with the pre-reveal
3909// assembly above and still applies live if it straggles past the cap.
3910function checkOwnership(s, host) {
3911 if (!spacesEnabled) return Promise.resolve();
3912 return apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } })
3913 .then((r) => {
3914 const row = (r.spaces || []).find((sp) => sp.host === host);
3915 if (row && archiving === s) {
3916 sternDestroyable = true;
3917 sternPaid = row.entitlement === "paid";
3918 // An owned space the ledger has SEEN (a sweep ran) but that was never
3919 // set up and never joined is an unusable shell — and when the entry
3920 // carries no credentials (never enrolled), no live check could ever
3921 // say otherwise. Promote destroy; deleting access would only strand
3922 // the quota slot.
3923 const token = s.credentials && s.credentials.token;
3924 sternShell = !token && !row.setup_complete && (row.members || 1) <= 1 && !!row.metered_at;
3925 prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule)
3926 updateSternHold();
3927 }
3928 })
3929 .catch(() => { /* can't confirm ownership — don't offer the destroy */ });
3930}
3931
3932// checkSoleAdmin asks the space itself — with the saved session token, straight
3933// from the browser so this server stays blind to which spaces the user holds —
3934// whether this key is its ONLY admin key (instances answer CORS for exactly this
3935// origin on /api/me). Fails OPEN: an unreachable space or an older build without
3936// the field must never make its entry undeletable, so the hold only ever engages
3937// on a positive answer.
3938function checkSoleAdmin(s) {
3939 const token = s.credentials && s.credentials.token;
3940 if (!token) return Promise.resolve();
3941 let url;
3942 try { url = new URL("/api/me?token=" + encodeURIComponent(token), s.url); } catch { return Promise.resolve(); }
3943 return fetch(url, { signal: AbortSignal.timeout(8000) })
3944 .then((r) => (r.ok ? r.json() : null))
3945 .then((me) => {
3946 if (soleAdminHold(me) && archiving === s) {
3947 sternSoleAdmin = true;
3948 updateSternHold();
3949 }
3950 })
3951 .catch(() => { /* no answer — no hold */ });
3952}
3953
3954// updateSternHold renders what the checks learned: the destroy zone when this
3955// account owns the space, and the last-admin hold — "Delete your access" goes
3956// (and stays) off. When BOTH hold (last admin of a space this account created),
3957// destroying is the only real action left, so it isn't offered as a footnote
3958// link under a dead button: the whole step flips to the destroy framing —
3959// title, copy, and the destroy button promoted to the primary slot. Rendering
3960// is declarative from the two flags, so straggling checks re-render cleanly.
3961function updateSternHold() {
3962 const destroyOnly = (sternSoleAdmin || sternShell) && sternDestroyable;
3963 $("stern-title").textContent = destroyOnly ? "Destroy this space?" : "Delete your access?";
3964 $("stern-intro").classList.toggle("hidden", destroyOnly);
3965 $("delete-access").classList.toggle("hidden", destroyOnly);
3966 $("destroy-zone").classList.toggle("hidden", !sternDestroyable);
3967 $("destroy-zone").classList.toggle("primary", destroyOnly);
3968 $("destroy-note").classList.toggle("hidden", destroyOnly);
3969 const destroy = $("destroy-forever");
3970 destroy.className = destroyOnly ? "btn-danger" : "linkish danger-link";
3971 destroy.textContent = destroyOnly ? "Destroy the space for everyone" : "Destroy the space for everyone…";
3972 $("destroy-paid").classList.toggle("hidden", !sternPaid);
3973 const note = $("sole-admin-note");
3974 // The subscription line rides every destroy framing for a paid space — the
3975 // one thing a payer needs to hear before the button.
3976 const paidLine = sternPaid ? ["Its subscription is cancelled with it — no further charges. "] : [];
3977 if (!sternSoleAdmin && !sternShell) { note.classList.add("hidden"); return; }
3978 if (sternShell) {
3979 const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent });
3980 const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." });
3981 note.replaceChildren(
3982 host, " was never set up — it has no members and no way in, but it still ",
3983 "counts toward your space limit. Destroying it frees that slot. ", ...paidLine, noUndo,
3984 );
3985 } else if (destroyOnly) {
3986 const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent });
3987 const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." });
3988 note.replaceChildren(
3989 "This is the only admin key for ", host, " — deleting just your access would ",
3990 "leave the space running with no admin, forever, and still counting toward ",
3991 "your space limit. You created it, so you can destroy the space for everyone ",
3992 "instead, which frees that slot: every message, photo, and member in it. ",
3993 ...paidLine, noUndo,
3994 );
3995 } else {
3996 note.textContent =
3997 "This is the space's only admin key. Deleting it would leave the space " +
3998 "running with no admin, forever. Make another member an admin first, then " +
3999 "come back to delete your access.";
4000 }
4001 note.classList.remove("hidden");
4002 $("delete-access").disabled = true;
4003}
4004
4005// After a delete/destroy empties the entry, return to wherever makes sense: the
4006// archive list if anything's left, else the main list.
4007function afterDelete(okMsg) {
4008 archiving = null;
4009 if (partitionArchived(vault.obj.servers).archived.length) {
4010 closeArchiveStern();
4011 renderArchiveList();
4012 archiveStatus(okMsg, "ok");
4013 } else {
4014 closeArchive();
4015 vaultStatus(okMsg, "ok");
4016 }
4017}
4018
4019// deleteAccess drops the saved entry from the vault — permanently. The space
4020// keeps running for everyone else; this only removes THIS account's saved way
4021// back in (the strongly-worded warning makes that consequence explicit).
4022async function deleteAccess() {
4023 if (!archiving || sternSoleAdmin) return; // the last-admin hold — see updateSternHold
4024 const s = archiving;
4025 vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url);
4026 // The tombstone makes the removal STICK: signed-in devices drop their copy
4027 // and stop re-pushing it, instead of resurrecting the entry on next sync.
4028 vault.obj.removed = addTombstone(vault.obj.removed, s.url, Date.now());
4029 archiveStatus("Deleting…", "busy");
4030 try {
4031 await saveVault();
4032 afterDelete("Removed from your account.");
4033 } catch (e) {
4034 archiveStatus(e.message || "Couldn't save the change.", "error");
4035 }
4036}
4037
4038// destroyForEveryone is the owner-only deeper action: a FRESH passkey ceremony
4039// (a link token alone can never destroy a space) then DELETE against the
4040// provisioner, which re-checks ownership against its ledger.
4041async function destroyForEveryone() {
4042 if (!archiving) return;
4043 const s = archiving;
4044 const host = $("stern-host").textContent;
4045 const btn = $("destroy-forever");
4046 btn.disabled = true;
4047 archiveStatus("Confirm it’s you…", "busy");
4048 let proof;
4049 try {
4050 ({ proof } = await runLogin(loginBegin));
4051 noteProof(proof); // fills the confirm-step pocket (Sign-in options)
4052 } catch (e) {
4053 prefetchLogin();
4054 archiveStatus(friendlyCeremonyError(e, "signin").message, "error");
4055 btn.disabled = false;
4056 return;
4057 }
4058 archiveStatus("Destroying the space…", "busy");
4059 try {
4060 const res = await fetch("/api/spaces/" + encodeURIComponent(host), {
4061 method: "DELETE",
4062 headers: { "X-Ceremony-Proof": proof },
4063 });
4064 const body = await res.json().catch(() => ({}));
4065 // 404 = not in the ledger (already gone / not yours) — treat "already gone"
4066 // as done; the list entry goes either way.
4067 if (!res.ok && res.status !== 404) {
4068 throw new Error(body.message || body.error || `Couldn't delete (HTTP ${res.status}).`);
4069 }
4070 vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url);
4071 vault.obj.removed = addTombstone(vault.obj.removed, s.url, Date.now());
4072 try { await saveVault(); } catch { /* the space is gone; the entry retries next save */ }
4073 afterDelete("Space destroyed.");
4074 } catch (e) {
4075 archiveStatus(e.message || String(e), "error");
4076 btn.disabled = false;
4077 }
4078}
4079
4080// ---- wiring -------------------------------------------------------------------
4081
4082const onSignInError = (err) => {
4083 // A friendly, specific line for what went wrong (ceremony-errors.js), with
4084 // the technical crumb parenthesised — it might just be the wrong passkey.
4085 // ANY failure or cancel reveals #newhere, the one place the create path
4086 // lives now: a cancelled prompt and a missing credential are deliberately
4087 // indistinguishable (NotAllowedError either way), so the explainer serves
4088 // both readings and only then offers "Create your passkey". It shows on
4089 // whichever surface ran the ceremony (main card or all-ways).
4090 setStatus(formatCeremonyError(err, "signin"), "error");
4091 revealNewhere();
4092 prefetchRegister(); // ready the create ceremony in case they're new
4093 setSignedOutBusy(false);
4094 prefetchLogin(); // options were spent — refresh for a retry
4095};
4096
4097const onCreatePasskeyError = (err) => {
4098 setStatus(formatCeremonyError(err, "create"), "error");
4099 setSignedOutBusy(false);
4100};
4101
4102// Arriving from a demo banner's "Keep this space" while signed out: say why
4103// you're looking at a sign-in page. The fragment waits; the claim screen
4104// opens on the first vault render after sign-in (or after creating one).
4105if (claimLinkHost && !(state.token && state.dek)) {
4106 setStatus("Sign in to keep your demo space.", "");
4107}
4108
4109// Arriving from a join page's sign-in-first card while signed out: say where
4110// this leads, host included (the display half of the redirect guard).
4111if (joinReturnURL() && !(state.token && state.dek)) {
4112 setStatus("Sign in, and you'll head straight back to " + hostOf(joinReturnURL()) + " to finish joining.", "");
4113}
4114
4115if (window.PublicKeyCredential) {
4116 // The passkey controls get pre-fetched ceremony options, so a click reaches
4117 // navigator.credentials.* inside the gesture (Safari activation). A browser
4118 // without WebAuthn keeps those controls off (setSignedOutBusy) — the Apple
4119 // and email doors still work.
4120 prefetchLogin();
4121 prefetchRegister();
4122}
4123composeCard(); // the #1112 card: one primary door, email beneath, the other-ways link
4124setSignedOutBusy(false); // paints the no-WebAuthn disable onto the passkey controls
4125$("signin").addEventListener("click", () => signIn().catch(onSignInError));
4126$("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError));
4127$("unlock-passkey").addEventListener("click", () => signIn().catch(onSignInError));
4128$("apple-signin").addEventListener("click", startAppleSignIn);
4129$("google-signin").addEventListener("click", (e) => startOidcSignIn("google", e));
4130$("microsoft-signin").addEventListener("click", (e) => startOidcSignIn("microsoft", e));
4131$("github-signin").addEventListener("click", (e) => startOidcSignIn("github", e));
4132// The instant the Apple confirmation proceeds, this page navigates away —
4133// the waiting face covers whatever frames remain (bfcache return releases it).
4134window.addEventListener("pagehide", engageSiwaCover);
4135// Coming BACK from Apple (bfcache restore) must not strand a spinning button.
4136window.addEventListener("pageshow", (e) => {
4137 if (!e.persisted) return;
4138 releaseApplePending();
4139});
4140$("cookie-ask").addEventListener("click", () => $("cookie-dialog").showModal());
4141$("cookie-close").addEventListener("click", () => $("cookie-dialog").close());
4142$("cookie-dialog").addEventListener("click", (e) => {
4143 if (e.target === $("cookie-dialog")) $("cookie-dialog").close(); // backdrop tap
4144});
4145$("email-signin").addEventListener("click", openEmailDoor);
4146$("email-back").addEventListener("click", closeEmailDoor);
4147$("email-form").addEventListener("submit", sendEmailCode);
4148$("email-code-form").addEventListener("submit", verifyEmailCode);
4149// The all-ways step: each door invokes the SAME handler as the main card's
4150// version — the step is another surface, never another code path.
4151$("other-ways").addEventListener("click", openOtherWays);
4152$("otherways-back").addEventListener("click", closeOtherWays);
4153$("otherways-apple").addEventListener("click", startAppleSignIn);
4154$("otherways-google").addEventListener("click", (e) => startOidcSignIn("google", e));
4155$("otherways-microsoft").addEventListener("click", (e) => startOidcSignIn("microsoft", e));
4156$("otherways-github").addEventListener("click", (e) => startOidcSignIn("github", e));
4157$("combine-oidc-go").addEventListener("click", (e) => {
4158 if (combine && OIDC_WEB[combine.kind]) startOidcSignIn(combine.kind, e, { intent: "merge-" + combine.kind, select: true });
4159});
4160$("otherways-email").addEventListener("click", openEmailDoor);
4161$("otherways-passkey").addEventListener("click", () => signIn().catch(onSignInError));
4162
4163// The SiwA web round trip lands back here as #siwa=<id_token>&state=web.<r>
4164// (siwa.go's trampoline — the fragment never reaches the server). The state
4165// must match the one this tab minted before leaving for Apple; the hash is
4166// cleared either way, so a reload can't replay the token.
4167let siwaBooting = false; // claims the boot — the cold-open path below stands down
4168{
4169 const m = location.hash.match(/^#siwa=([^&]*)&state=([^&]*)$/);
4170 if (m) {
4171 siwaBooting = true;
4172 // The pre-paint gate already put the waiting face up (html.siwa-wait) —
4173 // the clean card never shows.
4174 history.replaceState(null, "", location.pathname + location.search);
4175 const minted = sessionStorage.getItem("home_siwa_state") || "";
4176 sessionStorage.removeItem("home_siwa_state");
4177 // The methods step's round-trip markers (add-Apple's stashed proof, the
4178 // confirm-with-Apple return flag, the serialized in-progress intent) —
4179 // consumed exactly once, here, so a reload can't replay any of them.
4180 const linkMark = sessionStorage.getItem("home_siwa_link");
4181 const methodsReturn = !!sessionStorage.getItem("home_methods_return");
4182 const methodsIntent = sessionStorage.getItem("home_methods_intent") || "";
4183 sessionStorage.removeItem("home_siwa_link");
4184 sessionStorage.removeItem("home_methods_return");
4185 sessionStorage.removeItem("home_methods_intent");
4186 if (minted && decodeURIComponent(m[2]) === minted) {
4187 if (linkMark) finishSiwaLink(decodeURIComponent(m[1]), linkMark);
4188 else if (methodsIntent === "merge-apple") finishSiwaMergeProve(decodeURIComponent(m[1]));
4189 else finishSiwa(decodeURIComponent(m[1]), methodsReturn, methodsIntent);
4190 } else {
4191 // A state that didn't start here: straight to the signed-out card.
4192 finishHomeLoader({ immediate: true });
4193 setStatus("That Apple sign-in didn’t start here — try again.", "error");
4194 }
4195 }
4196}
4197
4198// The OIDC web round trips (Google today) land as #oidc=<id_token>&state=
4199// (oidc.go's provider-blind trampoline). Which door was opened — and the
4200// nonce the verifier must see round-trip — rides home_oidc_state, minted
4201// beside the state before departure. Same one-shot marker consumption as
4202// the #siwa= leg above.
4203let oidcBooting = false; // claims the boot — the cold-open path below stands down
4204if (!siwaBooting) {
4205 const m = location.hash.match(/^#oidc=([^&]*)&state=([^&]*)$/);
4206 if (m) {
4207 oidcBooting = true;
4208 history.replaceState(null, "", location.pathname + location.search);
4209 let rec = null;
4210 try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ }
4211 sessionStorage.removeItem("home_oidc_state");
4212 const linkMark = sessionStorage.getItem("home_siwa_link");
4213 const methodsReturn = !!sessionStorage.getItem("home_methods_return");
4214 const methodsIntent = sessionStorage.getItem("home_methods_intent") || "";
4215 sessionStorage.removeItem("home_siwa_link");
4216 sessionStorage.removeItem("home_methods_return");
4217 sessionStorage.removeItem("home_methods_intent");
4218 if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) {
4219 if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark);
4220 else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1]));
4221 else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent);
4222 } else {
4223 finishHomeLoader({ immediate: true });
4224 setStatus("That sign-in didn’t start here — try again.", "error");
4225 }
4226 }
4227}
4228
4229// The code-flow return leg (GitHub) lands as #oauth=<code>&state=web.<r>
4230// (github.go's GET trampoline). Same one-shot consumption; the code is
4231// single-use and worthless without the server-held secret either way.
4232let oauthBooting = false; // claims the boot — the cold-open path below stands down
4233if (!siwaBooting && !oidcBooting) {
4234 const m = location.hash.match(/^#oauth=([^&]*)&state=([^&]*)$/);
4235 if (m) {
4236 oauthBooting = true;
4237 history.replaceState(null, "", location.pathname + location.search);
4238 let rec = null;
4239 try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ }
4240 sessionStorage.removeItem("home_oidc_state");
4241 const linkMark = sessionStorage.getItem("home_siwa_link");
4242 const methodsReturn = !!sessionStorage.getItem("home_methods_return");
4243 const methodsIntent = sessionStorage.getItem("home_methods_intent") || "";
4244 sessionStorage.removeItem("home_siwa_link");
4245 sessionStorage.removeItem("home_methods_return");
4246 sessionStorage.removeItem("home_methods_intent");
4247 if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) {
4248 if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark);
4249 else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1]));
4250 else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent);
4251 } else {
4252 finishHomeLoader({ immediate: true });
4253 setStatus("That sign-in didn’t start here — try again.", "error");
4254 }
4255 }
4256}
4257
4258// The mail's magic link (email.go) lands here as #email=<address>&code=<6
4259// digits> — the same values the mail asks you to type, riding the fragment,
4260// which never reaches any server. It runs the ordinary /api/email/verify, so
4261// the code's single-use/expiry/attempt machinery is the whole story — and it
4262// works in any browser, recovery-grade, exactly like typing the code. The
4263// hash clears before the verify, so a reload can't rerun the (single-use
4264// anyway) code.
4265let emailLinkBooting = false; // claims the boot — the cold-open path below stands down
4266if (!siwaBooting && !oidcBooting && !oauthBooting && location.hash.indexOf("#email=") === 0) {
4267 emailLinkBooting = true;
4268 // The pre-paint gate already put the waiting face up (html.siwa-wait) —
4269 // the clean card never shows.
4270 const m = location.hash.match(/^#email=([^&]*)&code=(\d{6})$/);
4271 history.replaceState(null, "", location.pathname + location.search);
4272 if (m) {
4273 finishEmailLink(decodeURIComponent(m[1]), m[2]);
4274 } else {
4275 // A mangled link (a mail client truncated it): lift to the doors.
4276 finishHomeLoader({ immediate: true });
4277 setStatus("That sign-in link didn’t work — request a fresh code.", "error");
4278 }
4279}
4280
4281$("account-menu-btn").addEventListener("pointerdown", () => { kebabPointerOpen = true; });
4282$("account-menu-btn").addEventListener("click", () => {
4283 if (accountMenuOpen()) closeAccountMenu(true); else openAccountMenu();
4284});
4285// Sign out is technically instant (local state clears) — the ~700ms beat
4286// makes it read as the event it is (Paul's rule: meaningful transitions
4287// earn a working state; case-by-case, not universal).
4288$("signout").addEventListener("click", () => {
4289 closeAccountMenu(false);
4290 const card = document.querySelector("main.card");
4291 card.classList.add("waiting");
4292 setTimeout(() => {
4293 card.classList.remove("waiting");
4294 signOut();
4295 }, 700);
4296});
4297// Sign-in options (issue #1098): the kebab's serial step family. The list's
4298// own controls (row kebabs, first-add buttons) are wired per render.
4299$("how-signin").addEventListener("click", () => { closeAccountMenu(false); openMethods(); });
4300$("methods-back").addEventListener("click", closeMethods);
4301$("methods-confirm-back").addEventListener("click", () => {
4302 pendingIntent = null; // backing out cancels the parked change
4303 backToMethodsList("", "", { reload: false });
4304});
4305$("methods-confirm-passkey").addEventListener("click", confirmMethodsPasskey);
4306$("methods-confirm-apple").addEventListener("click", confirmMethodsApple);
4307$("methods-confirm-google").addEventListener("click", (e) => confirmMethodsOidc("google", e));
4308$("methods-confirm-microsoft").addEventListener("click", (e) => confirmMethodsOidc("microsoft", e));
4309$("methods-confirm-github").addEventListener("click", (e) => confirmMethodsOidc("github", e));
4310$("methods-confirm-email").addEventListener("click", confirmMethodsEmail);
4311$("methods-cemail-form").addEventListener("submit", cemailSend);
4312$("methods-cemail-code-form").addEventListener("submit", cemailVerify);
4313$("methods-email-back").addEventListener("click", () => backToMethodsList("", "", { reload: false }));
4314$("methods-email-form").addEventListener("submit", methodsEmailSend);
4315$("methods-email-code-form").addEventListener("submit", methodsEmailVerify);
4316$("methods-passkey-back").addEventListener("click", () => backToMethodsList("", "", { reload: false }));
4317$("methods-passkey-create").addEventListener("click", addPasskeyCeremony);
4318// The combine step (#1098's merge): offer → prove → its spaces → the merge.
4319$("combine-offer-go").addEventListener("click", openCombineStep);
4320$("combine-back").addEventListener("click", () => {
4321 combine = null; // cancel leaves both accounts untouched
4322 backToMethodsList("", "", { reload: false });
4323});
4324$("combine-code-form").addEventListener("submit", combineCodeVerify);
4325$("combine-resend").addEventListener("click", combineRequestCode);
4326$("combine-apple-go").addEventListener("click", combineAppleProve);
4327$("combine-unlock").addEventListener("click", combineUnlock);
4328$("combine-skip").addEventListener("click", combineSkip);
4329$("combine-skip-yes").addEventListener("click", combineSkipYes);
4330$("combine-skip-no").addEventListener("click", combineVaultStage);
4331$("open-create").addEventListener("click", openCreate);
4332$("plan-back").addEventListener("click", closePlan);
4333$("plan-continue").addEventListener("click", planContinue);
4334// Back from the slider: the list normally (it's the only step there was), or
4335// the plan screen when a bounce put one behind us — the chosen tier survives.
4336$("create-back").addEventListener("click", () => {
4337 if (planPaidRequired) {
4338 fader?.stop();
4339 $("create").classList.add("hidden");
4340 $("create-plan").classList.remove("hidden");
4341 return;
4342 }
4343 closeCreate();
4344});
4345$("create-done").addEventListener("click", closeCreate);
4346// (Creating a space is the fader now — mounted in openCreate; see fader.js.)
4347$("archive-back").addEventListener("click", closeArchive);
4348$("upgrade-back").addEventListener("click", closeUpgrade);
4349$("claim-back").addEventListener("click", closeClaim);
4350$("archive-cancel").addEventListener("click", closeArchiveStern);
4351$("delete-access").addEventListener("click", deleteAccess);
4352$("destroy-forever").addEventListener("click", destroyForEveryone);
4353// The typed address arms both delete actions — exact match only. The last-admin
4354// hold outranks arming for "Delete your access" (destroy is the sanctioned path,
4355// so it still arms).
4356$("delete-arm").addEventListener("input", () => {
4357 const armed = $("delete-arm").value.trim() === $("stern-host").textContent;
4358 $("delete-access").disabled = !armed || sternSoleAdmin;
4359 $("destroy-forever").disabled = !armed;
4360});
4361// Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input
4362// lives outside #spaces, so re-rendering the lists never steals its focus.
4363$("space-search").addEventListener("input", () => { if (vault) showVault(); });
4364
4365// Learn whether this home server offers self-serve space creation, and reveal
4366// the + on the list if it does (and we're already signed in — session rehydrate).
4367apiJSON("/api/features")
4368 .then((f) => {
4369 spacesEnabled = !!f.spaces;
4370 // The no-account demo path — newcomers only: a browser that has ever
4371 // signed in (the #1112 marker) has outgrown the demo card.
4372 $("demo-offer").classList.toggle("hidden", !f.demo || everSignedIn());
4373 if (vault) $("open-create").classList.toggle("hidden", !canCreateSpace());
4374 // If the list rendered before this answer, re-render: the lazy ledger read
4375 // (Upgrade affordance) only triggers once spacesEnabled is known.
4376 if (vault && !$("vault").classList.contains("hidden")) showVault();
4377 })
4378 .catch(() => { /* older home server without the endpoint: leave the + hidden */ });
4379
4380// The ambient smoke + motes background (bootscene.js) drifts behind every state
4381// of the page, for everyone — started once, left running.
4382startHomeLoader();
4383
4384// The composing veil (raised pre-paint by the inline gate) lifts only now —
4385// composeCard() has run, so the card's first visible frame is its final
4386// composition. compose-reveal keeps the lift near-instant.
4387document.documentElement.classList.add("compose-reveal");
4388document.documentElement.classList.remove("composing");
4389requestAnimationFrame(() => requestAnimationFrame(() =>
4390 document.documentElement.classList.remove("compose-reveal")));
4391
4392// Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in
4393// sessionStorage, so the vault can reopen without a ceremony. The loading chrome
4394// (the Eleven lockup + tagline, already up pre-paint via html.booting) hides the
4395// Sign-in flash until the list has decrypted; finishHomeLoader lifts it — fading
4396// the content in over the smoke — whether we land on the vault or, if the session
4397// went stale, back on Sign-in.
4398if (state.token && state.dek) {
4399 (async () => {
4400 try {
4401 const got = await fetchBlob(state.token);
4402 if (got) {
4403 dekKey = await importDEK(state.dek);
4404 const opened = await openBlob(got.bytes, dekKey);
4405 vault = { obj: opened.obj, keys: opened.keys, version: got.version };
4406 setStatus("");
4407 showVault();
4408 }
4409 } catch { /* token or DEK went stale; the button still works */ }
4410 finally { finishHomeLoader(); }
4411 })();
4412} else if (!siwaBooting && !oidcBooting && !oauthBooting && !emailLinkBooting) {
4413 // The cold open (stay signed in, Option A): no tab session, but a live
4414 // persisted token in localStorage. Adopt it and land exactly like an anchor
4415 // sign-in — the sealed prompt when a blob exists, the empty list when not;
4416 // the DEK was never persisted, so the passkey ceremony stays the only way
4417 // to plaintext. Acceptance rolls the 30-day expiry; a REJECTED token
4418 // (revoked server-side) clears the persisted session and falls to the
4419 // doors — a flaky network doesn't, it just gets this boot no further.
4420 // Every path lifts the pre-paint cover.
4421 const saved = persistedSession();
4422 if (saved) {
4423 (async () => {
4424 try {
4425 await enterAnchorSession({ token: saved.token, created: false });
4426 persistSession(saved.token, saved.method); // accepted — another 30 days
4427 } catch (e) {
4428 if (/\b401\b/.test((e && e.message) || "")) clearPersistedSession();
4429 } finally { finishHomeLoader(); }
4430 })();
4431 } else if (document.documentElement.classList.contains("booting")) {
4432 // The gate raised the cover for a session that didn't survive the read
4433 // (expired between frames, or unparseable) — tidy up and lift to the doors.
4434 clearPersistedSession();
4435 finishHomeLoader();
4436 }
4437}
4438