eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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 innerHTML untrusted 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 test and go test ./... green; node --check every touched JS file.
  • Changed welcome.flow.js must regenerate ios/LChatCore/Sources/LChatCore/Flows/welcome.json (drift-gated by test/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 the test script)

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 β€” in package.json, insert node test/prejoin.test.mjs && immediately after node test/backup.test.mjs && . Run npm 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.mjs after node home/test/sessionreuse.test.mjs in package.json. Run npm 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 the claimLinkHost parse ~line 170; signed-out status near the claimLinkHost status ~line 4073; consume at the top of showVault() ~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 claimLinkHost declaration (~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 claimLinkHost status 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 (new prejoin-tpl template)
  • Modify: web/static/join.js (imports, fragment restore, brandingName, showWelcome hook + caveat swap, new showPrejoin)

Interfaces:

  • Consumes: Task 1's helpers; bytesToB64url from crypto.js.
  • Produces: module-level prejoin ({signedUp, frag}) that Task 5's finish() change reads.
  • [ ] Step 1: Add the template to web/pages/join.html, directly above welcome-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: bytesToB64url in the crypto.js list, 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 serverHasAvatar declarations (~line 92) add let brandingName = "";, and inside the branding-decrypt block (after if (branding.name) {, ~line 145) add brandingName = branding.name; as its first line.
  • [ ] Step 4: Hook showWelcome. Change its signature to function showWelcome(kind, onContinue, spaceDesc = "", opts = {}) and insert after the if (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 showPrejoin after showWelcome (~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 (launchEnrol signature, ~line 241)
  • Modify: home/web/static/enroll.js (boot block, ~line 403)

Interfaces:

  • Consumes: Task 4's prejoin, backup.js's launchEnrol, enroll.js's existing trySessionReuse/applyEntry/succeed.
  • Produces: launchEnrol(session, opts) where opts.auto sets payload.auto β€” existing callers (shell.js and flows via the savePasskey verb) are unaffected.
  • [ ] Step 1: backup.js β€” the auto flag. Change export function launchEnrol(session) { to export function launchEnrol(session, opts = {}) {, and add to the payload object (after the native: 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 the if (window.__lchatEmbed) {...return;} block and await 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 pins launchEnrol behaviour; 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's sayIfNew)
  • 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's sayIfNew:
          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. Then npm 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 test and go 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.md for 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 as signup-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 off ENROLLED_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.mjs is 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=home setup 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.