# 🤖 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`:

```js
// 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: ERR_MODULE_NOT_FOUND for prejoin.js.

- [ ] **Step 3: Implement `web/static/prejoin.js`:**

```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`:

```js
// 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: ERR_MODULE_NOT_FOUND.

- [ ] **Step 3: Implement `home/web/static/joinreturn.js`:**

```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:

```js
import { parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash } from "/static/joinreturn.js";
```

- [ ] **Step 2: Boot-parse the fragment** — insert directly after the `claimLinkHost` declaration (~line 170):

```js
// 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()`:

```js
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):

```js
// 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):

```html
<!-- 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:

```js
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:

```js
// 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:

```js
  // 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:

```js
  // 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):

```js
// 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):

```js
    // 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:

```js
  // 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:

```js
  // 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

```js
assert.ok(h.transcript.find((t) => /^Great — welcome back\. 🔒/.test(t.text)));
```

to

```js
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`:

```js
          sayIfNew: "Great — you're signed in, so your access is safe. 🔒 Let's continue.",
```

- [ ] **Step 4: Regenerate the native bundle**:

```bash
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):

```markdown
🤖 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.
