eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The join page (see docs/threads-and-dms.md). An invite link is /join/<label>#<T>
2// (optionally #<T>~<name> for an add-device link). The fragment is never sent to
3// the server. Joining sets up the user's keypair — generated fresh for a new
4// person, or unwrapped from the link for an added device — registers the public
5// key in the roster, claims a device token, and stores the session.
6import {
7 decrypt,
8 decryptBytes,
9 deriveBrandingKey,
10 b64urlToBytes,
11 generateUserKeypair,
12 deriveInvite,
13 bindMac,
14 wrapThreadKey,
15 bytesToB64url,
16} from "/static/crypto.js";
17import { parseInviteFragment } from "/static/invite.js";
18import { appSchemeFrom } from "/static/ui.js";
19import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL, autoEnrolOnClaim } from "/static/prejoin.js";
20import { INVITER_HINT_KEY, inviterHintStash } from "/static/invitehint.js";
21import { launchEnrol } from "/static/backup.js";
22
23const SESSION_KEY = "lchat_session";
24const card = document.getElementById("card");
25const loader = document.getElementById("app-loading");
26
27// Which app scheme "Open in the app" fires — ui.js's appSchemeFrom (the
28// dev-alt override rides localStorage.lchat_app_scheme; see that helper).
29function appScheme() {
30 try {
31 return appSchemeFrom(localStorage.getItem("lchat_app_scheme") || "");
32 } catch {
33 return "elevenmessenger"; // no storage — the shipped app
34 }
35}
36
37// The ONE loading state: the same full-screen loader the app uses. It covers
38// the page until a card has real content, and again while a claim is in
39// flight — the card and the loader are never shown together.
40function showLoader() {
41 if (loader) loader.classList.remove("hidden");
42 card.classList.add("hidden");
43}
44function showCard() {
45 if (loader) loader.classList.add("hidden");
46 card.classList.remove("hidden");
47}
48const isStandalone =
49 window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
50
51// The pre-join sign-in round trip returns here as /join/<label>?su=<nonce>
52// with NO fragment — the invite secret parked in this origin's localStorage
53// before leaving (it never travels to the home origin). Verify the nonce,
54// recover the fragment (restoring it to the URL so "Open in the app" still
55// hands over a complete link), and scrub the marker.
56const prejoin = (() => {
57 let stored = "";
58 try { stored = localStorage.getItem(PREJOIN_STASH_KEY) || ""; } catch {}
59 const r = readPrejoinReturn({ search: location.search, storedJSON: stored, now: Date.now() });
60 if (r.signedUp) {
61 try { localStorage.removeItem(PREJOIN_STASH_KEY); } catch {}
62 try {
63 if (!location.hash && r.frag) history.replaceState(null, "", location.pathname + "#" + r.frag);
64 } catch {}
65 }
66 return r;
67})();
68const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1) || prejoin.frag);
69
70// ?src=home marks a link opened from the person's Eleven sign-in page (the
71// create-a-space flow). Two jobs, neither of them access: it stands the
72// signup-first lead card down (they signed in one tap ago — offerPrejoin), and
73// it's remembered on this origin so the setup conversation's key step can say
74// "add to your account" instead of pitching a sign-in from scratch.
75const fromHome = new URLSearchParams(location.search).get("src") === "home";
76if (fromHome) {
77 try { localStorage.setItem("lchat_from_home", "1"); } catch { /* storage denied: the generic copy shows */ }
78}
79
80// Single-fire guard for the claim: whatever path calls it (button, double tap,
81// Enter), only the first attempt proceeds; the rest are ignored.
82let claimStarted = false;
83function onceOnly(fn) {
84 return (...a) => {
85 if (claimStarted) return;
86 claimStarted = true;
87 return fn(...a);
88 };
89}
90
91init();
92
93function init() {
94 // Already installed? The link in this URL is likely spent — go to the app,
95 // which signs in via paste-to-join.
96 if (isStandalone && !window.__lchatEmbed) return location.replace("/");
97 if (!T) return showError("This link is missing its secret.", { signin: true });
98 start();
99}
100
101// The claim secret derived from T (proves knowledge of the invite secret when
102// claiming/adopting). Set once in start(); read by finish()/adoptExisting().
103let inviteClaimSecret = "";
104// The key-transparency binding key derived from T (#573): the joiner MACs its
105// published public key under it so the inviter (a T-holder) can verify the
106// roster key wasn't substituted. Set in start(), read by finish().
107let inviteBindKey = "";
108// The inviter's display name from the sealed payload (#1436), stashed at claim
109// time so the app labels them before the instance key arrives. Set in start().
110let inviterFrom = "";
111
112// Whether this instance has a branding photo. Fetched in start() and shown on
113// the onboarding cards in place of the generic emoji. On a sealed instance
114// (docs/no-plaintext.md) the photo is ciphertext: serverPhotoURL becomes a
115// decrypted blob: URL when the link carries the branding key, else the photo
116// (and the readable name/description) simply doesn't show — the link still
117// claims fine. See docs/onboarding-and-home-server.md.
118let serverHasAvatar = false;
119let serverAvatarAlt = "";
120let serverPhotoURL = "/api/server-avatar";
121// The sealed branding name, when this link's fragment can open it — the lead
122// card's h1 was server-baked with the generic fallback and needs the real name.
123let brandingName = "";
124
125async function start() {
126 let id, wrapKey, info;
127 try {
128 const derived = await deriveInvite(T);
129 id = derived.id;
130 wrapKey = derived.wrapKey;
131 inviteClaimSecret = derived.claimSecret;
132 inviteBindKey = derived.bindKey;
133 info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json();
134 } catch {
135 return showError("Couldn't reach the server. Try again.");
136 }
137 if (!info.ok) {
138 // A dead link in an existing member's hands is just a stale bookmark —
139 // they're already in, so go to the chat instead of an error card telling
140 // them to ask for an invite they don't need. liveSession (not a raw
141 // localStorage read) so a token from a reset instance still falls through
142 // to the honest error. Embed keeps the error: the native add-chat webview
143 // owns navigation, and its shell expects a join, not a redirect.
144 const existing = window.__lchatEmbed ? null : await liveSession();
145 if (existing) return location.replace("/");
146 return showError("This invite has been used or is no longer valid. Ask for a new one.", { signin: true });
147 }
148
149 // The space's description rides on the invite lookup (holders of a valid link
150 // only — it's never on the public read), so the welcome card can say what
151 // this place is before the joiner holds any key.
152 let spaceDesc = typeof info.description === "string" ? info.description : "";
153 // A cohort invite names the access level the joiner lands in (plaintext by
154 // design, like the description — served to link-holders only).
155 cohortName = typeof info.tier_name === "string" ? info.tier_name : "";
156
157 // Note whether this instance has a branding photo (a public flag), so the
158 // onboarding cards below can show it instead of the placeholder emoji.
159 let groupInfo = {};
160 try {
161 groupInfo = await (await fetch("/api/group")).json();
162 serverHasAvatar = !!groupInfo.avatar;
163 serverAvatarAlt = groupInfo.avatar_alt || "";
164 } catch {}
165
166 // Sealed branding: this link's fragment carries the branding key, so open
167 // the blob and render the real name/description/photo in place of the
168 // defaults. A link without the key (minted before sealing) still claims —
169 // the card just stays generic. Decrypt failures are treated the same way.
170 if (info.branding_blob && info.branding_salt && fragB) {
171 try {
172 const bKey = await deriveBrandingKey(fragB, info.branding_salt);
173 const branding = await decrypt(bKey, info.branding_blob);
174 if (branding.name) {
175 brandingName = branding.name;
176 document.title = branding.name;
177 // The h1 was server-baked with the generic fallback (the server can't
178 // read the sealed name); the standard join card never re-sets it, so
179 // fill it here. The device/adopt/admin kinds overwrite it later.
180 const h1 = document.querySelector("#welcome-title");
181 if (h1) h1.textContent = `You're invited to ${branding.name}`;
182 }
183 if (branding.description) spaceDesc = branding.description;
184 serverAvatarAlt = branding.avatar_alt || serverAvatarAlt;
185 // Post-claim, the app bootstraps the branding key from here (it also
186 // has the instance-key-sealed copy as a fallback). Stash it before the
187 // photo work below: a failed photo decrypt must not cost the handoff.
188 try { localStorage.setItem("lchat_branding_b", fragB); } catch {}
189 if (serverHasAvatar && groupInfo.sealed) {
190 try {
191 const sealedBytes = await (await fetch("/api/server-avatar")).text();
192 const photo = await decryptBytes(bKey, b64urlToBytes(sealedBytes));
193 serverPhotoURL = URL.createObjectURL(new Blob([photo], { type: branding.avatar_mime || "image/jpeg" }));
194 } catch {
195 serverHasAvatar = false; // ciphertext isn't an image — show the placeholder
196 }
197 }
198 } catch { /* stay generic */ }
199 } else if (groupInfo.sealed && serverHasAvatar) {
200 // Sealed photo but no key in this link: the ciphertext isn't an image.
201 serverHasAvatar = false;
202 }
203
204 // Everything below only *decides* what continuing means for this link — the
205 // claim itself waits for an explicit choice on the welcome card. Claiming on
206 // page-load burned the one-time secret in whatever context rendered the link
207 // first (a hidden webview, a second tab) — see docs/onboarding-and-home-server.md.
208 let payload = null;
209 if (info.wrapped) {
210 try {
211 payload = await decrypt(wrapKey, info.wrapped);
212 } catch {
213 return showError("Couldn't unlock this link (wrong or corrupt secret).", { signin: true });
214 }
215 }
216
217 if (payload && payload.priv) {
218 // Add-device link: it carries this user's keypair (and name) — signing in
219 // is the only possible action, but it still waits for the click.
220 return showWelcome("device", () => {
221 showStatus("Signing you in…");
222 finish(id, fragName || "Me", payload.pub, payload.priv);
223 });
224 }
225 if (payload && payload.threadKey) {
226 // The inviter's display name rides the sealed payload (#1436): remember it
227 // so the app's first paint can label them before the instance key lands.
228 inviterFrom = typeof payload.from === "string" ? payload.from.trim().slice(0, 64) : "";
229 // User invite: it drops the joiner into a DM with the person who invited them.
230 // In the native embed the shell owns identities (Keychain) — never adopt a
231 // stray webview session; always run the fresh-person flow and hand back.
232 const existing = window.__lchatEmbed ? null : await liveSession();
233 if (existing && existing.token && existing.userPub && existing.userPriv) {
234 // Already a member on this device → join that DM as your existing self.
235 return showWelcome("adopt", () => {
236 showStatus("Opening your chat…");
237 adoptExisting(id, payload.threadKey, existing);
238 });
239 }
240 // New person: chat-first — claim nameless and let Eleven's welcome
241 // conversation ask the name in-chat (the inviter's suggestion rides along
242 // in the pending marker). The native add-chat webview keeps the form: it
243 // hands a NAMED identity straight back to the shell.
244 if (window.__lchatEmbed) {
245 return showWelcome(
246 "join",
247 () =>
248 showForm(fragName, async (name) => {
249 showStatus("Setting up…");
250 const kp = await generateUserKeypair();
251 const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
252 finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf);
253 }),
254 spaceDesc,
255 );
256 }
257 return showWelcome(
258 "join",
259 async () => {
260 showStatus("Setting up…");
261 const kp = await generateUserKeypair();
262 const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
263 markWelcomePending(fragName);
264 finish(id, "", kp.publicKey, kp.privateKey, wrappedSelf);
265 },
266 spaceDesc,
267 );
268 }
269
270 // Setup invite (admin-granting): if already logged in, promote the existing
271 // account. Otherwise, create a new admin account.
272 if (info.grants_admin) {
273 const existing = window.__lchatEmbed ? null : await liveSession();
274 if (existing && existing.token && existing.userPub && existing.userPriv) {
275 // Already a member: promote to admin on existing account.
276 return showWelcome("adopt-admin", () => {
277 showStatus("Promoting to admin…");
278 adoptAdminExisting(id, existing);
279 });
280 }
281 // New person becoming admin: check if this instance's setup is already complete.
282 // If so, skip the "setup" flow label and just proceed normally — the app won't
283 // trigger setup conversation if setup_complete is true.
284 let setupNeeded = true;
285 try {
286 const me = await (await fetch("/api/me")).json();
287 setupNeeded = !me.setup_complete;
288 } catch {}
289 const setupLabel = setupNeeded ? "setup" : "join";
290 return showWelcome(setupLabel, async () => {
291 showStatus("Setting up…");
292 const kp = await generateUserKeypair();
293 finish(id, "", kp.publicKey, kp.privateKey);
294 });
295 }
296
297 // New person (or unknown payload shape): if already logged in, adopt this
298 // invite as your existing self. Otherwise, start a new account.
299 const existing = window.__lchatEmbed ? null : await liveSession();
300 if (existing && existing.token && existing.userPub && existing.userPriv) {
301 // Already a member: adopt this invite as your existing self (no wrapped key needed).
302 return showWelcome("adopt", () => {
303 showStatus("Opening your chat…");
304 adoptInviteExisting(id, existing);
305 });
306 }
307 // New person: same chat-first dance — the name is Eleven's first question,
308 // not a form (native add-chat keeps its form).
309 if (window.__lchatEmbed) {
310 return showWelcome(
311 "join",
312 () =>
313 showForm(fragName, async (name) => {
314 showStatus("Setting up…");
315 const kp = await generateUserKeypair();
316 finish(id, name, kp.publicKey, kp.privateKey);
317 }),
318 spaceDesc,
319 );
320 }
321 showWelcome(
322 "join",
323 async () => {
324 showStatus("Setting up…");
325 const kp = await generateUserKeypair();
326 markWelcomePending(fragName);
327 finish(id, "", kp.publicKey, kp.privateKey);
328 },
329 spaceDesc,
330 );
331}
332
333// The pending marker hands the joiner to Eleven's welcome conversation on the
334// app's first load (shell.js maybeStartWelcome), carrying the inviter's
335// suggested name. Local-only — the name suggestion never touches the server.
336// A new invite pasted into an already-open join tab changes only the URL
337// fragment — a same-document navigation, so nothing re-runs and the old
338// card (or error) just sits there. Reload to process the new link.
339addEventListener("hashchange", () => location.reload());
340
341function markWelcomePending(suggested) {
342 try {
343 localStorage.setItem("lchat_welcome_pending", JSON.stringify({ suggested: suggested || "" }));
344 } catch {}
345}
346
347// The welcome card: the invite is inspected (a non-consuming GET) but nothing is
348// claimed until a button is pressed. In the native add-chat flow (__lchatEmbed)
349// the person already chose explicitly in the app, so continue straight on.
350let cohortName = ""; // cohort invite: the level the joiner will be part of
351
352// `spaceDesc` is the space's admin-set description (from the invite lookup).
353function showWelcome(kind, onContinue, spaceDesc = "", opts = {}) {
354 if (window.__lchatEmbed) return onContinue();
355 // Signup-first: a fresh joiner leads with the sign-in door, not the app/web
356 // choice (skippable; established identities and native contexts never see it).
357 if (!opts.skipPrejoin && offerPrejoin({
358 kind,
359 homeOrigin: window.__homeOrigin || "",
360 embed: !!window.__lchatEmbed,
361 native: document.documentElement.classList.contains("native"),
362 signedUp: prejoin.signedUp,
363 fromHome, // came straight from a signed-in home page — don't ask twice
364 })) {
365 return showPrejoin(kind, onContinue, spaceDesc);
366 }
367 card.classList.add("onboard");
368 card.classList.remove("status");
369 card.innerHTML = "";
370 showCard();
371 const tpl = document.getElementById("welcome-tpl");
372 card.appendChild(tpl.content.cloneNode(true));
373 applyServerPhoto(card);
374 if (spaceDesc) {
375 const d = card.querySelector("#welcome-desc");
376 d.textContent = spaceDesc.slice(0, 600);
377 d.classList.remove("hidden");
378 }
379 if (cohortName && kind !== "device") {
380 // The cohort line sits under the subtitle: arriving somewhere and being
381 // told which circle you're joining is half the welcome.
382 const c = document.createElement("p");
383 c.className = "sub";
384 c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120);
385 const sub = card.querySelector("#welcome-sub");
386 if (sub) sub.after(c);
387 }
388 // Back from the pre-join sign-in: the browser-only caveat is no longer true —
389 // their access rides their account (finish() saves the session to it).
390 if (prejoin.signedUp) {
391 const cav = card.querySelector("#welcome-caveat");
392 if (cav) cav.textContent =
393 "Your access will be saved to your Eleven account — you can get back in from any device.";
394 }
395 if (kind === "device") {
396 // document.title carries the substituted group name.
397 card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`;
398 card.querySelector("#welcome-sub").textContent = "This link signs this device in as you.";
399 card.querySelector("#welcome-caveat").remove();
400 card.querySelector("#welcome-web").textContent = "Sign in here in the browser";
401 } else if (kind === "adopt") {
402 const groupName = document.title.replace("You're invited to ", "");
403 card.querySelector("#welcome-title").textContent = `You're already in ${groupName}`;
404 card.querySelector("#welcome-sub").textContent =
405 "Continue to open the new conversation on this device.";
406 card.querySelector("#welcome-caveat").remove();
407 card.querySelector("#welcome-web").textContent = "Continue";
408 } else if (kind === "adopt-admin") {
409 card.querySelector("#welcome-title").textContent = "Become admin";
410 card.querySelector("#welcome-sub").textContent =
411 "You're already a member on this device. Continue to accept the admin role.";
412 card.querySelector("#welcome-caveat").remove();
413 card.querySelector("#welcome-web").textContent = "Accept";
414 } else if (kind === "setup") {
415 card.querySelector("#welcome-title").textContent = "Welcome to your new space";
416 card.querySelector("#welcome-sub").textContent =
417 "This one-time link makes you its administrator. Continue, and we'll set things up together.";
418 card.querySelector("#welcome-caveat").remove();
419 card.querySelector("#welcome-web").textContent = "Let's get started";
420 }
421 // "Open in the app" hands the full link — fragment included — to the installed
422 // app via its scheme; the scheme URL never reaches any server. There's no way
423 // to detect the app from here: if nothing claims the scheme this is a no-op
424 // and the other buttons stand. Inside the Mac app's own webview it's noise.
425 const appBtn = card.querySelector("#welcome-app");
426 if (document.documentElement.classList.contains("native")) {
427 appBtn.closest(".row").remove();
428 } else {
429 appBtn.onclick = () => {
430 location.href = appScheme() + "://add?url=" + encodeURIComponent(location.href);
431 };
432 }
433 // "Get the app" is per-instance config (LCHAT_APP_STORE_URL); hidden until set.
434 const storeURL = tpl.dataset.storeUrl || "";
435 const storeBtn = card.querySelector("#welcome-store");
436 if (storeURL) storeBtn.onclick = () => window.open(storeURL, "_blank", "noopener");
437 else storeBtn.closest(".row").remove();
438 const webBtn = card.querySelector("#welcome-web");
439 const go = onceOnly(onContinue);
440 webBtn.onclick = () => {
441 webBtn.disabled = true; // instant feedback — no dead-feeling first tap
442 go();
443 };
444}
445
446// The signup-first lead card: sign in with Eleven BEFORE anything is claimed.
447// The tap parks the invite fragment (and a fresh nonce) on THIS origin and
448// heads to the home server's doors; home returns to /join/<label>?su=<nonce>.
449// Skipping falls through to the classic welcome card — zero-friction stays.
450function showPrejoin(kind, onContinue, spaceDesc) {
451 card.classList.add("onboard");
452 card.classList.remove("status");
453 card.innerHTML = "";
454 showCard();
455 card.appendChild(document.getElementById("prejoin-tpl").content.cloneNode(true));
456 applyServerPhoto(card);
457 // The h1 was server-baked with the generic fallback on sealed instances.
458 if (brandingName) card.querySelector("#prejoin-title").textContent = `You're invited to ${brandingName}`;
459 if (kind === "setup") {
460 card.querySelector("#prejoin-title").textContent = "Welcome to your new space";
461 card.querySelector("#prejoin-sub").textContent =
462 "This one-time link makes you its administrator. Sign in first, and your access is saved from the start.";
463 }
464 if (spaceDesc) {
465 const d = card.querySelector("#prejoin-desc");
466 d.textContent = spaceDesc.slice(0, 600);
467 d.classList.remove("hidden");
468 }
469 if (cohortName) {
470 const c = document.createElement("p");
471 c.className = "sub";
472 c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120);
473 card.querySelector("#prejoin-sub").after(c);
474 }
475 card.querySelector("#prejoin-signin").onclick = () => {
476 const b = new Uint8Array(16);
477 crypto.getRandomValues(b);
478 const nonce = bytesToB64url(b);
479 try {
480 localStorage.setItem(PREJOIN_STASH_KEY, prejoinStash({ frag: location.hash.slice(1), nonce, now: Date.now() }));
481 } catch {
482 // No storage: the fragment can't survive the round trip — fall through to
483 // the classic card rather than strand the secret.
484 return showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true });
485 }
486 location.assign(homeSigninURL(window.__homeOrigin, location.origin, location.pathname, nonce));
487 };
488 card.querySelector("#prejoin-skip").onclick = () =>
489 showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true });
490}
491
492async function finish(id, name, pub, priv, wrappedSelf = "") {
493 let claim;
494 try {
495 claim = await (
496 await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, {
497 method: "POST",
498 headers: { "Content-Type": "application/json" },
499 // The screen name is never sent to the server — it's published encrypted
500 // (POST /api/label) once the app loads and has the instance key.
501 body: JSON.stringify({ wrapped: wrappedSelf, claim_secret: inviteClaimSecret }),
502 })
503 ).json();
504 } catch {
505 localStorage.removeItem("lchat_welcome_pending"); // the claim didn't take — drop the marker
506 return showError("Couldn't reach the server. Try again.");
507 }
508 if (!claim.ok || !claim.token) {
509 localStorage.removeItem("lchat_welcome_pending");
510 return showError("This invite was just used. Ask for a new one.");
511 }
512 // Key transparency (#573): MAC our public key under the T-derived bind key so
513 // the inviter can verify it's the key WE published. Computed OUTSIDE the
514 // publish try — a bind-MAC hiccup must never skip the load-bearing pubkey
515 // registration (that would leave a claimed-but-keyless broken join).
516 let bindMacVal = "";
517 try {
518 if (inviteBindKey) bindMacVal = await bindMac(inviteBindKey, pub);
519 } catch {}
520 try {
521 await fetch("/api/pubkey", {
522 method: "POST",
523 headers: { "Content-Type": "application/json" },
524 body: JSON.stringify({ token: claim.token, public_key: pub, bind_mac: bindMacVal }),
525 });
526 } catch {}
527 if (!window.__lchatEmbed) {
528 localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv }));
529 // Land straight in the DM this invite created (the app opens it on boot).
530 if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id);
531 // First-paint label for the inviter (#1436) — provisional until their real
532 // label decrypts; shell.js applies and clears it.
533 if (claim.thread_id && inviterFrom) {
534 try {
535 localStorage.setItem(INVITER_HINT_KEY, inviterHintStash({ thread: claim.thread_id, name: inviterFrom, now: Date.now() }));
536 } catch {}
537 }
538 }
539 if (window.__lchatEmbed) {
540 // Native add-instance flow: hand the resolved identity back to the shell, which
541 // moves the private key into the Keychain. Don't navigate — the shell tears the
542 // one-shot join webview down once it has this.
543 try {
544 window.webkit.messageHandlers.lchat.postMessage({
545 joined: { token: claim.token, name, userPub: pub, userPriv: priv },
546 });
547 } catch {}
548 return;
549 }
550 // Already signed in on the way here: save this brand-new session to the
551 // account NOW, via the /enroll round trip. Two arrivals qualify — the
552 // signup-first joiner (prejoin), and someone who created this space from
553 // their Eleven page (?src=home). Either way the home-tab session from
554 // minutes ago is still live, so enrolment completes hands-free
555 // (payload.auto) and lands back at "/" with the standard nonce marker.
556 //
557 // Doing it HERE is what makes signing in a once-per-space act. The
558 // from-home creator used to be asked again at the end of the setup
559 // conversation (keys-home) — and, until it was answered, home's list held
560 // only the address-only stub the create saved, with none of this space's
561 // key. One automatic enrolment at claim time settles both: the key is in
562 // the account before Eleven says hello, so the conversation's key step
563 // sees bridge.enrolled and simply confirms it.
564 //
565 // If the session has gone stale, /enroll's own card stands (enroll.js
566 // never runs a ceremony unprompted) — one honest ask, in one place.
567 if (autoEnrolOnClaim({
568 signedUp: prejoin.signedUp, fromHome,
569 homeOrigin: window.__homeOrigin || "", embed: !!window.__lchatEmbed,
570 })) {
571 return launchEnrol({ token: claim.token, name, userPub: pub, userPriv: priv }, { auto: true });
572 }
573 await plugIn();
574 location.href = "/"; // drop the fragment from the URL bar
575}
576
577// adoptExisting joins the invite's pending DM as the member already signed in on
578// this device (rather than creating a new person). We wrap the DM key to our
579// existing public key and let the server swap us in for the placeholder.
580async function adoptExisting(id, threadKeyB64, existing) {
581 try {
582 const wrapped = await wrapThreadKey(threadKeyB64, existing.userPub);
583 const r = await (
584 await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
585 method: "POST",
586 headers: { "Content-Type": "application/json" },
587 body: JSON.stringify({ token: existing.token, wrapped, claim_secret: inviteClaimSecret }),
588 })
589 ).json();
590 if (!r.ok) {
591 return showError("This invite has been used or is no longer valid. Ask for a new one.");
592 }
593 if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id);
594 await plugIn();
595 location.href = "/";
596 } catch {
597 showError("Couldn't reach the server. Try again.");
598 }
599}
600
601// adoptAdminExisting claims an admin-granting invite on an existing account,
602// promoting the existing member to admin without creating a new user account.
603async function adoptAdminExisting(id, existing) {
604 try {
605 const r = await (
606 await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
607 method: "POST",
608 headers: { "Content-Type": "application/json" },
609 body: JSON.stringify({ token: existing.token, wrapped: "", claim_secret: inviteClaimSecret }),
610 })
611 ).json();
612 if (!r.ok) {
613 return showError("This invite has been used or is no longer valid. Ask for a new one.");
614 }
615 // Token stays the same (existing session is preserved). Just redirect back to app.
616 await plugIn();
617 location.href = "/";
618 } catch {
619 showError("Couldn't reach the server. Try again.");
620 }
621}
622
623// adoptInviteExisting claims a generic invite (no wrapped payload) on an existing
624// account, letting them adopt it as their existing self without creating a new user.
625async function adoptInviteExisting(id, existing) {
626 try {
627 const r = await (
628 await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, {
629 method: "POST",
630 headers: { "Content-Type": "application/json" },
631 body: JSON.stringify({ token: existing.token, wrapped: "", claim_secret: inviteClaimSecret }),
632 })
633 ).json();
634 if (!r.ok) {
635 return showError("This invite has been used or is no longer valid. Ask for a new one.");
636 }
637 if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id);
638 await plugIn();
639 location.href = "/";
640 } catch {
641 showError("Couldn't reach the server. Try again.");
642 }
643}
644
645// liveSession: loadSession, but verified against the server. A stored session
646// can outlive its account — an instance reset (or this device's removal)
647// leaves a token the server no longer knows, and trusting it routes a fresh
648// invite down the "you're already in" adopt path, which then dies with a
649// misleading "invite no longer valid". Drop the dead session and fall through
650// to the fresh-person flow ONLY on a definitive 403 — a network failure keeps
651// the session (dropping keys because wifi blipped would be catastrophic for a
652// real member), and the adopt path then fails loudly as before.
653async function liveSession() {
654 const existing = loadSession();
655 if (!existing || !existing.token || !existing.userPub || !existing.userPriv) return null;
656 try {
657 const r = await fetch(`/api/me?token=${encodeURIComponent(existing.token)}`);
658 if (r.status === 403) {
659 localStorage.removeItem(SESSION_KEY);
660 return null;
661 }
662 } catch {}
663 return existing;
664}
665
666function loadSession() {
667 try {
668 return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
669 } catch {
670 return null;
671 }
672}
673
674// ---- views --------------------------------------------------------------
675
676// Reveal the instance branding photo on a freshly-rendered onboarding card (and
677// hide the placeholder emoji) when one is set. The <img> src is our own
678// same-origin endpoint — never fragment or user input.
679function applyServerPhoto(root) {
680 if (!serverHasAvatar) return;
681 const img = root.querySelector(".onboard-photo");
682 if (!img) return;
683 img.src = serverPhotoURL; // same-origin endpoint, or a decrypted blob: URL when sealed
684 img.alt = serverAvatarAlt; // the admin's alt text travels with the photo
685 img.classList.remove("hidden");
686 const emoji = root.querySelector(".onboard-icon");
687 if (emoji) emoji.classList.add("hidden");
688}
689
690function showForm(defaultName, onSubmit) {
691 card.classList.remove("onboard", "status");
692 card.innerHTML = "";
693 card.appendChild(document.getElementById("form-tpl").content.cloneNode(true));
694 showCard();
695 applyServerPhoto(card);
696 const input = card.querySelector("#name");
697 if (defaultName) input.value = defaultName;
698 input.focus();
699 input.select();
700 card.querySelector("#join-form").onsubmit = (e) => {
701 e.preventDefault();
702 const name = input.value.trim();
703 if (name) onSubmit(name);
704 };
705}
706
707// In-flight states ("Setting up…", "Signing you in…") all render as the one
708// full-screen loader — the message argument is accepted for call-site clarity
709// but the loader speaks for itself.
710function showStatus(_msg) {
711 showLoader();
712}
713
714// The beat between "claimed" and the app: flash "Plugging in…" under the
715// spinner, fade it out, then navigate — the amp knob fades in on the far side.
716async function plugIn() {
717 if (!loader) return;
718 const msg = document.createElement("div");
719 msg.className = "plugging-in";
720 msg.textContent = "Plugging in…";
721 loader.appendChild(msg);
722 requestAnimationFrame(() => msg.classList.add("show"));
723 await new Promise((r) => setTimeout(r, 1000));
724 msg.classList.remove("show");
725 await new Promise((r) => setTimeout(r, 300));
726}
727
728function showError(msg, opts = {}) {
729 card.classList.remove("onboard", "status");
730 card.innerHTML = "";
731 card.appendChild(document.getElementById("error-tpl").content.cloneNode(true));
732 card.querySelector("#error-msg").textContent = msg;
733 // A dead invite can be a returning member's stale bookmark on a device whose
734 // session is gone — they don't need a new invite, they need the sign-in door
735 // (their account restores their spaces from home). Opt-in per call site so
736 // unrelated errors ("couldn't reach the server") don't grow a misleading CTA.
737 // The startsWith guard also covers a dev page served without substitution.
738 const home = window.__homeOrigin || "";
739 if (opts.signin && !window.__lchatEmbed && home.startsWith("http")) {
740 const btn = card.querySelector("#error-signin");
741 btn.classList.remove("hidden");
742 btn.onclick = () => { location.href = home; };
743 }
744 showCard();
745}
746
747