eleven / ocho Public
π€ Signup-First Join Page Implementation Plan
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Fresh joiners opening /join/<name>#<secret> see a sign-in-first lead card; after a round-trip to the home server's doors they return signed in, and their claimed session is auto-saved to their account before they land in the chat. Spec: docs/superpowers/specs/2026-07-31-signup-first-join-design.md.
Architecture: A new lead card on the join page navigates (same tab) to home with #joinret=<return url>; home parks it in sessionStorage (surviving OAuth door round-trips) and the first vault render after sign-in redirects back to /join/<label>?su=<nonce>. The invite #fragment never leaves the instance origin β it parks in instance localStorage keyed by the nonce. After the claim, launchEnrol(session, {auto:true}) bounces through /enroll, whose live-session fast path saves the space hands-free and returns to / with the standard #lchat_backup nonce, so ENROLLED_KEY is set before Eleven's welcome conversation starts and its sign-in ask self-skips.
Tech Stack: Vanilla ES modules, no build step. Pure helpers in new web/static/prejoin.js + home/web/static/joinreturn.js, Node-tested (node test/*.mjs). No Go changes.
Global Constraints
- Never
innerHTMLuntrusted content; build DOM/textContent(static in-code strings OK). - The invite fragment/secret must NEVER reach any server or the home origin.
- The deferred-claim invariant is untouched: nothing claims until a button press.
- No floating toasts; all feedback inline.
- New prose/comments are π€-marked where file convention applies (Markdown docs get π€; JS comments unmarked as per existing style).
npm testandgo test ./...green;node --checkevery touched JS file.- Changed
welcome.flow.jsmust regenerateios/LChatCore/Sources/LChatCore/Flows/welcome.json(drift-gated bytest/flow.test.mjs). - Commits prefixed by area:
web:,home:,docs:.
---
Task 1: web/static/prejoin.js β pure instance-side decision helpers
Files:
- Create:
web/static/prejoin.js - Test:
test/prejoin.test.mjs - Modify:
package.json(add test to thetestscript)
Interfaces:
- Produces:
PREJOIN_STASH_KEY(const"lchat_prejoin"),offerPrejoin({kind, homeOrigin, embed, native, signedUp}) β bool,prejoinStash({frag, nonce, now}) β string,readPrejoinReturn({search, storedJSON, now}) β {signedUp, frag},homeSigninURL(homeOrigin, origin, pathname, nonce) β string. Task 4 (join.js) consumes all of these.
- [ ] Step 1: Write the failing test β
test/prejoin.test.mjs:
// Tests for the signup-first join page's pure decision helpers (prejoin.js).
import test from "node:test";
import assert from "node:assert/strict";
import {
PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL,
} from "../web/static/prejoin.js";
const HOME = "https://home.example.com";
const base = { kind: "join", homeOrigin: HOME, embed: false, native: false, signedUp: false };
test("offerPrejoin: fresh join/setup kinds with a home server", () => {
assert.equal(offerPrejoin(base), true);
assert.equal(offerPrejoin({ ...base, kind: "setup" }), true);
});
test("offerPrejoin: never for established or unsupported contexts", () => {
for (const kind of ["device", "adopt", "adopt-admin"]) {
assert.equal(offerPrejoin({ ...base, kind }), false);
}
assert.equal(offerPrejoin({ ...base, homeOrigin: "" }), false);
assert.equal(offerPrejoin({ ...base, homeOrigin: "__HOME_ORIGIN__" }), false);
assert.equal(offerPrejoin({ ...base, embed: true }), false);
assert.equal(offerPrejoin({ ...base, native: true }), false);
assert.equal(offerPrejoin({ ...base, signedUp: true }), false);
});
test("round trip: stash then read back on a genuine return", () => {
const now = 1_000_000;
const stored = prejoinStash({ frag: "e1.SECRET~Ann~bkey", nonce: "n0nce", now });
const r = readPrejoinReturn({ search: "?su=n0nce", storedJSON: stored, now: now + 5000 });
assert.deepEqual(r, { signedUp: true, frag: "e1.SECRET~Ann~bkey" });
});
test("readPrejoinReturn: no marker, forged nonce, stale stash, clock skew", () => {
const now = 1_000_000;
const stored = prejoinStash({ frag: "f", nonce: "good", now });
assert.deepEqual(readPrejoinReturn({ search: "", storedJSON: stored, now }),
{ signedUp: false, frag: "" });
assert.deepEqual(readPrejoinReturn({ search: "?su=evil", storedJSON: stored, now }),
{ signedUp: false, frag: "" });
assert.deepEqual(readPrejoinReturn({ search: "?su=good", storedJSON: stored, now: now + 61 * 60 * 1000 }),
{ signedUp: false, frag: "" });
assert.deepEqual(readPrejoinReturn({ search: "?su=good", storedJSON: stored, now: now - 1000 }),
{ signedUp: false, frag: "" });
assert.deepEqual(readPrejoinReturn({ search: "?su=good", storedJSON: "not json", now }),
{ signedUp: false, frag: "" });
assert.deepEqual(readPrejoinReturn({ search: "?su=good", storedJSON: "", now }),
{ signedUp: false, frag: "" });
});
test("homeSigninURL: return URL rides the fragment, encoded, no invite secret", () => {
const url = homeSigninURL(HOME, "https://chat.example.com", "/join/abc", "n+once");
assert.equal(url,
HOME + "/#joinret=" + encodeURIComponent("https://chat.example.com/join/abc?su=" + encodeURIComponent("n+once")));
assert.equal(homeSigninURL(HOME + "/", "https://c.example.com", "/join/x", "n"),
HOME + "/#joinret=" + encodeURIComponent("https://c.example.com/join/x?su=n"));
});
test("stash key is stable", () => assert.equal(PREJOIN_STASH_KEY, "lchat_prejoin"));- [ ] Step 2: Run it to verify it fails:
node test/prejoin.test.mjsβ expected: ERRMODULENOT_FOUND for prejoin.js.
- [ ] Step 3: Implement
web/static/prejoin.js:
// Pure decision helpers for the signup-first join page (docs/superpowers/specs/
// 2026-07-31-signup-first-join-design.md): which links lead with the sign-in
// card, and how the round trip to the home server is stamped so a forged or
// stale ?su= marker never reads as "signed up". No DOM, no storage β join.js
// owns those β so this is Node-testable (test/prejoin.test.mjs).
export const PREJOIN_STASH_KEY = "lchat_prejoin";
// A pre-join round trip should be minutes; an hour-old stash is an abandoned
// one β reading it as a fresh sign-up would put a stale "saved" promise on the
// welcome card.
const MAX_AGE_MS = 60 * 60 * 1000;
// offerPrejoin: the lead card shows only to a FRESH person (join/setup kinds β
// device links and adopts already have an identity), only where a home server
// exists to sign into, and never in the native embed/wrapper (the apps own
// their sign-in). signedUp means the round trip already happened.
export function offerPrejoin({ kind, homeOrigin, embed, native, signedUp }) {
if (embed || native || signedUp) return false;
if (kind !== "join" && kind !== "setup") return false;
return typeof homeOrigin === "string" && homeOrigin.startsWith("http");
}
// prejoinStash packages what must survive the navigation away: the invite
// fragment (the secret NEVER travels to the home origin β it waits here, on
// the instance origin) and the nonce the return marker must echo.
export function prejoinStash({ frag, nonce, now }) {
return JSON.stringify({ v: 1, frag: String(frag || ""), nonce: String(nonce || ""), at: now });
}
// readPrejoinReturn: ?su=<nonce> marks a return from the home sign-in. It
// counts only when the nonce matches a fresh stash β anyone can type ?su=1,
// but only this browser holds the stash minted before leaving.
export function readPrejoinReturn({ search, storedJSON, now }) {
const none = { signedUp: false, frag: "" };
const su = new URLSearchParams(search || "").get("su") || "";
if (!su) return none;
let stash = null;
try { stash = JSON.parse(storedJSON || "null"); } catch { return none; }
if (!stash || typeof stash.nonce !== "string" || stash.nonce !== su) return none;
if (typeof stash.at !== "number" || now < stash.at || now - stash.at > MAX_AGE_MS) return none;
return { signedUp: true, frag: String(stash.frag || "") };
}
// homeSigninURL builds the door: the home page reads #joinret=<url> (a
// fragment, so the return URL never appears in home's server logs) and sends
// the person back to it once signed in. Only the nonce rides the return URL.
export function homeSigninURL(homeOrigin, origin, pathname, nonce) {
const ret = origin + pathname + "?su=" + encodeURIComponent(nonce);
return homeOrigin.replace(/\/$/, "") + "/#joinret=" + encodeURIComponent(ret);
}- [ ] Step 4: Run tests to verify they pass:
node test/prejoin.test.mjsβ expected: all pass.
- [ ] Step 5: Wire into
npm testβ inpackage.json, insertnode test/prejoin.test.mjs &&immediately afternode test/backup.test.mjs &&. Runnpm test(full) β expected: green.
- [ ] Step 6: Commit:
git add web/static/prejoin.js test/prejoin.test.mjs package.json && git commit -m "web: prejoin.js β pure helpers for the signup-first join card"
---
Task 2: home/web/static/joinreturn.js β pure home-side return helpers
Files:
- Create:
home/web/static/joinreturn.js - Test:
home/test/joinreturn.test.mjs - Modify:
package.json(add test)
Interfaces:
- Produces:
parseJoinReturn(hash) β string,validJoinReturn(url) β bool,stashJoinReturn(url, now) β string,readJoinReturnStash(json, now) β string. Task 3 (home.js) consumes all.
- [ ] Step 1: Write the failing test β
home/test/joinreturn.test.mjs:
// Tests for the pre-join sign-in return helpers (joinreturn.js) β the guard
// that decides where a signed-in person may be redirected back to.
import test from "node:test";
import assert from "node:assert/strict";
import {
parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash,
} from "../web/static/joinreturn.js";
const OK = "https://chat.example.com/join/abc?su=n1";
test("parseJoinReturn: the #joinret= fragment, decoded", () => {
assert.equal(parseJoinReturn("#joinret=" + encodeURIComponent(OK)), OK);
assert.equal(parseJoinReturn(""), "");
assert.equal(parseJoinReturn("#claim=host"), "");
assert.equal(parseJoinReturn("#joinret=%"), ""); // malformed encoding
});
test("validJoinReturn: https /join/ URLs only (http for localhost dev)", () => {
assert.equal(validJoinReturn(OK), true);
assert.equal(validJoinReturn("http://localhost:8080/join/abc?su=n"), true);
assert.equal(validJoinReturn("http://127.0.0.1:8080/join/abc"), true);
assert.equal(validJoinReturn("http://chat.example.com/join/abc"), false);
assert.equal(validJoinReturn("https://chat.example.com/other"), false);
assert.equal(validJoinReturn("https://u:p@chat.example.com/join/abc"), false);
assert.equal(validJoinReturn("javascript:alert(1)"), false);
assert.equal(validJoinReturn("not a url"), false);
});
test("stash round trip, expiry, tamper", () => {
const now = 5_000_000;
const s = stashJoinReturn(OK, now);
assert.equal(readJoinReturnStash(s, now + 1000), OK);
assert.equal(readJoinReturnStash(s, now + 61 * 60 * 1000), "");
assert.equal(readJoinReturnStash(s, now - 1000), "");
assert.equal(readJoinReturnStash("junk", now), "");
assert.equal(readJoinReturnStash("", now), "");
// A stash whose URL fails the guard reads as absent β validated on BOTH ends.
assert.equal(readJoinReturnStash(stashJoinReturn("https://evil.example.com/x", now), now), "");
});- [ ] Step 2: Run to verify failure:
node home/test/joinreturn.test.mjsβ expected: ERRMODULENOT_FOUND.
- [ ] Step 3: Implement
home/web/static/joinreturn.js:
// Pure helpers for the pre-join sign-in return (the signup-first join flow):
// a join page sends its visitor here with #joinret=<url>, and once signed in
// the first vault render sends them straight back. These helpers are the
// guard on that redirect β Node-tested (home/test/joinreturn.test.mjs).
// A sign-in should take minutes; an hour-old return intent is abandoned, and
// honouring it would teleport a later sign-in in this tab somewhere unexpected.
const MAX_AGE_MS = 60 * 60 * 1000;
// parseJoinReturn pulls the return URL out of "#joinret=<encoded url>".
export function parseJoinReturn(hash) {
const m = /^#joinret=(.+)$/.exec(hash || "");
if (!m) return "";
try { return decodeURIComponent(m[1]); } catch { return ""; }
}
// validJoinReturn: only ever send a signed-in person to an https /join/ page
// (http allowed for localhost dev). Instance hosts are arbitrary custom
// domains, so no host allowlist is possible β the signed-out status line
// displays the destination host instead, and the path/scheme pin means the
// worst a crafted link can do is land someone on a join page they can close.
export function validJoinReturn(url) {
let u;
try { u = new URL(url); } catch { return false; }
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1";
if (u.protocol !== "https:" && !(u.protocol === "http:" && local)) return false;
if (u.username || u.password) return false;
return u.pathname.startsWith("/join/");
}
// The sessionStorage shape. Parked (not held in a variable) because the anchor
// doors (Apple/Google/β¦) leave this page and re-enter it β a fragment or a
// variable wouldn't survive the trip; tab-scoped sessionStorage does.
export function stashJoinReturn(url, now) {
return JSON.stringify({ v: 1, url: String(url), at: now });
}
export function readJoinReturnStash(json, now) {
let s = null;
try { s = JSON.parse(json || "null"); } catch { return ""; }
if (!s || typeof s.url !== "string" || typeof s.at !== "number") return "";
if (now < s.at || now - s.at > MAX_AGE_MS) return "";
return validJoinReturn(s.url) ? s.url : "";
}- [ ] Step 4: Run tests:
node home/test/joinreturn.test.mjsβ expected: pass.
- [ ] Step 5: Wire into
npm testβ append&& node home/test/joinreturn.test.mjsafternode home/test/sessionreuse.test.mjsinpackage.json. Runnpm testβ green.
- [ ] Step 6: Commit:
git add home/web/static/joinreturn.js home/test/joinreturn.test.mjs package.json && git commit -m "home: joinreturn.js β pure guard for the pre-join sign-in return"
---
Task 3: home.js β park the return intent, redirect back after sign-in
Files:
- Modify:
home/web/static/home.js(imports at top; boot-parse near theclaimLinkHostparse ~line 170; signed-out status near theclaimLinkHoststatus ~line 4073; consume at the top ofshowVault()~line 2479)
Interfaces:
- Consumes: Task 2's four functions.
- Produces: the behaviour Task 4 relies on β home lands back on
/join/<label>?su=<nonce>once signed in (instantly when already signed in).
- [ ] Step 1: Add the import at the top of
home/web/static/home.js, alongside its existing/static/imports:
import { parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash } from "/static/joinreturn.js";- [ ] Step 2: Boot-parse the fragment β insert directly after the
claimLinkHostdeclaration (~line 170):
// A join page sends its visitor here to sign in FIRST (#joinret=<url> β the
// signup-first join flow). The return URL parks in sessionStorage so it
// survives the anchor doors' round trips (Apple/Google leave this page and
// come back via their trampolines, dropping the hash), and the first vault
// render after sign-in sends the person straight back to finish joining
// instead of showing the space list. Guarded both ends: joinreturn.js pins
// the destination to an https /join/ path.
{
const jr = parseJoinReturn(location.hash);
if (jr && validJoinReturn(jr)) {
try { sessionStorage.setItem("home_join_return", stashJoinReturn(jr, Date.now())); } catch { /* no storage β plain sign-in */ }
history.replaceState(null, "", location.pathname + location.search);
}
}
function joinReturnURL() {
try { return readJoinReturnStash(sessionStorage.getItem("home_join_return") || "", Date.now()); } catch { return ""; }
}
// consumeJoinReturn: the redirect back. True = navigating; callers stop rendering.
function consumeJoinReturn() {
const url = joinReturnURL();
if (!url) return false;
try { sessionStorage.removeItem("home_join_return"); } catch {}
location.replace(url);
return true;
}- [ ] Step 3: Redirect on the first vault render β first line of
showVault():
function showVault() {
if (consumeJoinReturn()) return; // pre-join sign-in: back to the join page, not the list- [ ] Step 4: Say why you're looking at a sign-in page β next to the
claimLinkHoststatus block (~line 4073):
// Arriving from a join page's sign-in-first card while signed out: say where
// this leads, host included (the display half of the redirect guard).
if (joinReturnURL() && !(state.token && state.dek)) {
setStatus("Sign in, and you'll head straight back to " + hostOf(joinReturnURL()) + " to finish joining.", "");
}- [ ] Step 5: Verify:
node --check home/web/static/home.jsβ clean;npm testβ green.
- [ ] Step 6: Commit:
git add home/web/static/home.js && git commit -m "home: pre-join sign-in mode β park #joinret, redirect back once signed in"
---
Task 4: join page β the lead card, fragment stash/restore, caveat-free return
Files:
- Modify:
web/pages/join.html(newprejoin-tpltemplate) - Modify:
web/static/join.js(imports, fragment restore,brandingName,showWelcomehook + caveat swap, newshowPrejoin)
Interfaces:
- Consumes: Task 1's helpers;
bytesToB64urlfromcrypto.js. - Produces: module-level
prejoin({signedUp, frag}) that Task 5'sfinish()change reads.
- [ ] Step 1: Add the template to
web/pages/join.html, directly abovewelcome-tpl(~line 116):
<!-- The signup-first lead card (docs/superpowers/specs/2026-07-31-signup-first-
join-design.md): fresh joiners see the sign-in door BEFORE the app/web
choice, so their access is saved to their account from the start. Skippable
β "Continue without an account" reveals the classic welcome card. -->
<template id="prejoin-tpl">
<img id="prejoin-photo" class="onboard-photo hidden" alt="" />
<div class="onboard-icon" id="prejoin-icon">π¬</div>
<h1 id="prejoin-title">You're invited to __GROUP_NAME__</h1>
<p id="prejoin-sub">Private, end-to-end encrypted chat.</p>
<p id="prejoin-desc" class="welcome-desc hidden"></p>
<div class="row"><button id="prejoin-signin">Continue with your Eleven sign-in</button></div>
<div class="row"><button id="prejoin-skip" class="secondary">Continue without an account</button></div>
<p class="welcome-caveat">
Signing in first keeps your access safe β get back into this space from any
device, with nothing to remember.
</p>
</template>- [ ] Step 2: join.js β imports and the return leg. Add to the imports:
bytesToB64urlin thecrypto.jslist, plus:
import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL } from "/static/prejoin.js";
import { launchEnrol } from "/static/backup.js";Replace line 47 (const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1));) with:
// The pre-join sign-in round trip returns here as /join/<label>?su=<nonce>
// with NO fragment β the invite secret parked in this origin's localStorage
// before leaving (it never travels to the home origin). Verify the nonce,
// recover the fragment (restoring it to the URL so "Open in the app" still
// hands over a complete link), and scrub the marker.
const prejoin = (() => {
let stored = "";
try { stored = localStorage.getItem(PREJOIN_STASH_KEY) || ""; } catch {}
const r = readPrejoinReturn({ search: location.search, storedJSON: stored, now: Date.now() });
if (r.signedUp) {
try { localStorage.removeItem(PREJOIN_STASH_KEY); } catch {}
try {
if (!location.hash && r.frag) history.replaceState(null, "", location.pathname + "#" + r.frag);
} catch {}
}
return r;
})();
const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1) || prejoin.frag);- [ ] Step 3: Remember the sealed branding name. Next to the
serverHasAvatardeclarations (~line 92) addlet brandingName = "";, and inside the branding-decrypt block (afterif (branding.name) {, ~line 145) addbrandingName = branding.name;as its first line.
- [ ] Step 4: Hook
showWelcome. Change its signature tofunction showWelcome(kind, onContinue, spaceDesc = "", opts = {})and insert after theif (window.__lchatEmbed) return onContinue();line:
// Signup-first: a fresh joiner leads with the sign-in door, not the app/web
// choice (skippable; established identities and native contexts never see it).
if (!opts.skipPrejoin && offerPrejoin({
kind,
homeOrigin: window.__homeOrigin || "",
embed: !!window.__lchatEmbed,
native: document.documentElement.classList.contains("native"),
signedUp: prejoin.signedUp,
})) {
return showPrejoin(kind, onContinue, spaceDesc);
}Then, after the cohort block (if (cohortName && kind !== "device") {...}, before the if (kind === "device") branch), insert the caveat swap:
// Back from the pre-join sign-in: the browser-only caveat is no longer true β
// their access rides their account (finish() saves the session to it).
if (prejoin.signedUp) {
const cav = card.querySelector("#welcome-caveat");
if (cav) cav.textContent =
"Your access will be saved to your Eleven account β you can get back in from any device.";
}- [ ] Step 5: Add
showPrejoinaftershowWelcome(~line 393):
// The signup-first lead card: sign in with Eleven BEFORE anything is claimed.
// The tap parks the invite fragment (and a fresh nonce) on THIS origin and
// heads to the home server's doors; home returns to /join/<label>?su=<nonce>.
// Skipping falls through to the classic welcome card β zero-friction stays.
function showPrejoin(kind, onContinue, spaceDesc) {
card.classList.add("onboard");
card.classList.remove("status");
card.innerHTML = "";
showCard();
card.appendChild(document.getElementById("prejoin-tpl").content.cloneNode(true));
applyServerPhoto(card);
// The h1 was server-baked with the generic fallback on sealed instances.
if (brandingName) card.querySelector("#prejoin-title").textContent = `You're invited to ${brandingName}`;
if (kind === "setup") {
card.querySelector("#prejoin-title").textContent = "Welcome to your new space";
card.querySelector("#prejoin-sub").textContent =
"This one-time link makes you its administrator. Sign in first, and your access is saved from the start.";
}
if (spaceDesc) {
const d = card.querySelector("#prejoin-desc");
d.textContent = spaceDesc.slice(0, 600);
d.classList.remove("hidden");
}
if (cohortName) {
const c = document.createElement("p");
c.className = "sub";
c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120);
card.querySelector("#prejoin-sub").after(c);
}
card.querySelector("#prejoin-signin").onclick = () => {
const b = new Uint8Array(16);
crypto.getRandomValues(b);
const nonce = bytesToB64url(b);
try {
localStorage.setItem(PREJOIN_STASH_KEY, prejoinStash({ frag: location.hash.slice(1), nonce, now: Date.now() }));
} catch {
// No storage: the fragment can't survive the round trip β fall through to
// the classic card rather than strand the secret.
return showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true });
}
location.assign(homeSigninURL(window.__homeOrigin, location.origin, location.pathname, nonce));
};
card.querySelector("#prejoin-skip").onclick = () =>
showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true });
}- [ ] Step 6: Verify:
node --check web/static/join.jsβ clean;npm testβ green.
- [ ] Step 7: Commit:
git add web/pages/join.html web/static/join.js && git commit -m "web: signup-first join β lead card, fragment stash, caveat-free return"
---
Task 5: post-claim auto-save β finish() bounce + launchEnrol auto + enroll.js fast path
Files:
- Modify:
web/static/join.js(finish(), ~line 429-447) - Modify:
web/static/backup.js(launchEnrolsignature, ~line 241) - Modify:
home/web/static/enroll.js(boot block, ~line 403)
Interfaces:
- Consumes: Task 4's
prejoin, backup.js'slaunchEnrol, enroll.js's existingtrySessionReuse/applyEntry/succeed. - Produces:
launchEnrol(session, opts)whereopts.autosetspayload.autoβ existing callers (shell.js and flows via thesavePasskeyverb) are unaffected.
- [ ] Step 1: backup.js β the
autoflag. Changeexport function launchEnrol(session) {toexport function launchEnrol(session, opts = {}) {, and add to the payload object (after thenative: stay,line):
// auto: a post-claim handoff from the signup-first join β /enroll tries its
// live-session fast path without a tap (and only that; see enroll.js).
auto: !!opts.auto,- [ ] Step 2: join.js β the bounce. In
finish(), between theif (window.__lchatEmbed) {...return;}block andawait plugIn();, insert:
// Signed up before joining: save this brand-new session to the account NOW,
// via the /enroll round trip. The home-tab session from the sign-in moments
// ago is still live, so enrolment completes hands-free (payload.auto) and
// lands back at "/" with the standard nonce marker β the joiner arrives in
// the chat already backed up, and Eleven never has to ask.
if (prejoin.signedUp && (window.__homeOrigin || "").startsWith("http")) {
return launchEnrol({ token: claim.token, name, userPub: pub, userPriv: priv }, { auto: true });
}- [ ] Step 3: enroll.js β the tap-free fast path. At the end of the final boot branch (after the
$("actions").append(back);line, ~line 416), insert:
// A post-claim handoff from the signup-first join (payload.auto): the tab
// signed in at home moments ago, so try the live-session fast path without
// a tap. Auto NEVER runs a ceremony and never skips the replace guard β if
// the session can't complete the save silently, the normal card stands and
// the buttons above do their usual work.
if (payload.auto) {
disable();
trySessionReuse()
.then((done) => { if (!done) enable(); })
.catch((e) => onError(e, "signin"));
}- [ ] Step 4: Verify:
node --check web/static/join.js web/static/backup.js home/web/static/enroll.jsβ clean;npm testβ green (backup.test.mjs pinslaunchEnrolbehaviour; the added optional param must not break it β if it does, the test told us something real: fix the code, not the test).
- [ ] Step 5: Commit:
git add web/static/join.js web/static/backup.js home/web/static/enroll.js && git commit -m "web+home: post-claim auto-save β enrol the fresh session hands-free after a signup-first join"
---
Task 6: Eleven stands down β flow copy + regenerated native bundle
Files:
- Modify:
web/static/welcome.flow.js(~line 53, the access entry'ssayIfNew) - Modify:
test/flow.test.mjs(lines ~789 and ~831, the pinned copy regexes) - Regenerate:
ios/LChatCore/Sources/LChatCore/Flows/welcome.json
Why: with ENROLLED_KEY set before the app boots, the access entry (if: {any: ["bridge.enrolled", "done"]}) already records-and-skips β no logic change needed. But its ack says "Great β welcome back. π β¦", which reads wrong when the joiner never left mid-conversation (they signed up before it started). Reword to cover both arrivals.
- [ ] Step 1: Update the pinned tests first β in
test/flow.test.mjs, change both occurrences of
assert.ok(h.transcript.find((t) => /^Great β welcome back\. π/.test(t.text)));to
assert.ok(h.transcript.find((t) => /^Great β you're signed in/.test(t.text)));- [ ] Step 2: Run to verify failure:
node test/flow.test.mjsβ expected: the two assertions fail (copy not yet changed).
- [ ] Step 3: Change the copy β in
web/static/welcome.flow.js, the access entry'ssayIfNew:
sayIfNew: "Great β you're signed in, so your access is safe. π Let's continue.",- [ ] Step 4: Regenerate the native bundle:
node -e 'import("./web/static/welcome.flow.js").then(async m => { (await import("fs")).writeFileSync("ios/LChatCore/Sources/LChatCore/Flows/welcome.json", JSON.stringify(m.default, null, 2) + "\n"); })'- [ ] Step 5: Run tests:
node test/flow.test.mjsβ expected: pass, including the native-bundle drift gate. Thennpm testβ green.
- [ ] Step 6: Commit:
git add web/static/welcome.flow.js test/flow.test.mjs ios/LChatCore/Sources/LChatCore/Flows/welcome.json && git commit -m "web: welcome flow ack covers the signup-first arrival (not just the return leg)"
---
Task 7: changelog + doc touch-up
Files:
- Create:
changelog/2026-07-31-signup-first-join.md - Modify:
docs/onboarding-and-home-server.md(Β§1 note that the sign-in-first golden path shipped)
- [ ] Step 1: Changelog entry (member-facing, π€ on the first line):
π€ Joining from an invite link now starts with your sign-in. Create (or use)
your Eleven sign-in first, and your access to the new space is saved to your
account before you even choose between browser and app β no way to lose your
way in. Prefer not to? "Continue without an account" keeps the familiar
one-tap join.- [ ] Step 2: Doc note β in
docs/onboarding-and-home-server.mdΒ§1 (the golden-path steps around lines 73-88), add a short π€ paragraph after the step list: the sign-in-first ordering shipped 2026-07-31 as the join page's lead card (skippable;web/static/prejoin.js,home/web/static/joinreturn.js), with the post-claim auto-enrol bounce; the caveat-free second welcome card renders when the joiner returns signed in. Do not rewrite the surrounding prose (respect any π¨ markers; the file's banner already flags superseded identity invariants β leave it).
- [ ] Step 3: Commit:
git add changelog/2026-07-31-signup-first-join.md docs/onboarding-and-home-server.md && git commit -m "docs: changelog + onboarding doc note for the signup-first join"
---
Task 8: full verification + ship
- [ ] Step 1: Full suites:
npm testandgo test ./...β both green (no Go changes, but the embedded-pages tests must still pass with the new template). - [ ] Step 2: Syntax-check every touched JS file:
node --check web/static/join.js web/static/prejoin.js web/static/backup.js web/static/welcome.flow.js home/web/static/home.js home/web/static/joinreturn.js home/web/static/enroll.js - [ ] Step 3: Manual-verification preference β read
CLAUDE.local.mdfor the recorded choice (canary vs localhost); note it in the PR. Deployment ordering note for the PR body: home deploys before instances (a new join page against an old home strands the joiner on home's vault with no redirect back; old join page + new home is inert). - [ ] Step 4: Push branch + draft PR (worktree branch
worktree-signup-first-joinβ rename/push assignup-first-join), PR body covering: what/why, the trust notes (fragment never travels; nonce-verified return; auto-enrol guards), deploy ordering, and stated out-of-scope items from the spec.
Self-review notes
- Spec coverage: Β§1βTask 4, Β§2βTask 1 (
offerPrejoin) + existing adopt/device routing untouched, Β§3βTasks 2-3, Β§4βTasks 1+4, Β§5βTasks 4-5, Β§6βTask 6 (plus existing banner/finish conditions which key offENROLLED_KEYβ no change needed), Β§7βall test steps + Task 8. - The spec's "Go test on home for the ?return guard" is corrected to a Node test: the guard is client-side (home's Go server never sees the fragment), so
home/test/joinreturn.test.mjsis where it's testable. Spec intent (the guard is tested) is preserved. - The spec's "after the account exists and a passkey is saved" is implemented as "signed in with the vault unlocked" (any door): home's own surfaces own passkey nudging, and an anchor-signed-in account already makes access recoverable β the actual goal. Noted deliberately.
?src=homesetup links (creator arriving from home, already signed in) still see the lead card; their tap is an instant bounce (home redirects immediately). Accepted β one redundant tap beats special-casing a marker that can go stale.