eleven / messenger Public

web+home: signup-first join — sign in before the app/web choice (#1407)

* docs: signup-first join page design spec

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* docs: signup-first join implementation plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* web: prejoin.js — pure helpers for the signup-first join card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* home: joinreturn.js — pure guard for the pre-join sign-in return

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* home: pre-join sign-in mode — park #joinret, redirect back once signed in

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* web: signup-first join — lead card, fragment stash, caveat-free return

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* web+home: post-claim auto-save — enrol the fresh session hands-free after a signup-first join

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* web: welcome flow ack covers the signup-first arrival (not just the return leg)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* docs: changelog + onboarding doc note for the signup-first join

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

* deploy: dev canaries point at the dev home server (ops canary --home-origin)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNqQpvjVQZi9J7ymJL3VsG

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f18e44264dd1d84c4e2641105912879dbe6de8c4 parent 9f68407
19 files changed, +1150 −10
  • changelog/2026-07-31-signup-first-join.md +5 −0
  • deploy/ops-canary.sh +14 −1
  • docs/onboarding-and-home-server.md +14 −0
  • docs/planet-scale-guide/deploys.html +1 −1
  • docs/superpowers/plans/2026-07-31-signup-first-join.md +622 −0
  • docs/superpowers/specs/2026-07-31-signup-first-join-design.md +128 −0
  • home/test/joinreturn.test.mjs +39 −0
  • home/web/static/enroll.js +12 −0
  • home/web/static/home.js +33 −0
  • home/web/static/joinreturn.js +44 −0
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +1 −1
  • package.json +1 −1
  • test/flow.test.mjs +2 −2
  • test/prejoin.test.mjs +59 −0
  • web/pages/join.html +18 −0
  • web/static/backup.js +4 −1
  • web/static/join.js +98 −2
  • web/static/prejoin.js +51 −0
  • web/static/welcome.flow.js +4 −1
diff --git a/changelog/2026-07-31-signup-first-join.md b/changelog/2026-07-31-signup-first-join.md
new file mode 100644
index 0000000..49dfb6b
--- /dev/null
+++ b/changelog/2026-07-31-signup-first-join.md
@@ -0,0 +1,5 @@
+🤖 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.
diff --git a/deploy/ops-canary.sh b/deploy/ops-canary.sh
index 8917e6a..8240e3f 100755
--- a/deploy/ops-canary.sh
+++ b/deploy/ops-canary.sh
@@ -47,6 +47,17 @@ root="$(CDPATH= cd -- "$here/.." && pwd)"
# shared 50/week 11ch.at LE budget — fine at canary cadence, not for bursts.
BOX="${LCHAT_CANARY_BOX:-elevendev-fi}"
DOMAIN="${LCHAT_CANARY_DOMAIN:-fi.dev.11ch.at}"
+# The home server a canary's instance points at (LCHAT_HOME_ORIGIN — the
+# sign-in/enrol origin baked into its pages). Dev canaries must ride the DEV
+# home: without this, add-instance leaves the env empty and the binary falls
+# back to the compiled PRODUCTION home — a dev canary whose sign-in round
+# trips hit prod (old code, real accounts). Keyed off the domain so the
+# deliberate prod exception (LCHAT_CANARY_DOMAIN=11ch.at) keeps the compiled
+# default; LCHAT_CANARY_HOME_ORIGIN overrides either way.
+case "$DOMAIN" in
+ *.dev.*) HOME_ORIGIN="${LCHAT_CANARY_HOME_ORIGIN:-https://home.dev.elevenmessenger.com}" ;;
+ *) HOME_ORIGIN="${LCHAT_CANARY_HOME_ORIGIN:-}" ;;
+esac
LCHAT=/opt/lchat/lchat
usage() {
@@ -80,7 +91,9 @@ create)
echo "ops canary: creating $host on $BOX (flagged --canary)"
# add-instance prints the admin link + the one-time setup link — both carry
# secrets, so the full output is staged box-side and echoed here redacted.
- out="$(ssh "$BOX" "sudo -n $LCHAT add-instance --canary '$host' > \$HOME/$host.link 2>&1; st=\$?; cat \$HOME/$host.link; exit \$st")" || {
+ homeflag=""
+ [ -n "$HOME_ORIGIN" ] && homeflag="--home-origin '$HOME_ORIGIN'"
+ out="$(ssh "$BOX" "sudo -n $LCHAT add-instance --canary $homeflag '$host' > \$HOME/$host.link 2>&1; st=\$?; cat \$HOME/$host.link; exit \$st")" || {
printf '%s\n' "$out" | redact_secrets >&2
exit 1
}
diff --git a/docs/onboarding-and-home-server.md b/docs/onboarding-and-home-server.md
index 930dc77..7c3f74f 100644
--- a/docs/onboarding-and-home-server.md
+++ b/docs/onboarding-and-home-server.md
@@ -86,6 +86,20 @@ URL bar. Claim happens only on an explicit choice.
3. Joiners who skipped sign-in get a post-join nudge instead ("Don't lose
access to this chat — save a passkey"), Apple-style, after they've arrived.
+🤖 *Shipped 2026-07-31 (signup-first join):* the sign-in-first ordering above
+now exists as the join page's **lead card** — fresh joiners see "Continue with
+your Eleven sign-in" before the app/web choice, with a quiet "Continue without
+an account" escape. The round trip differs from the sketch in two details:
+it's always a same-tab navigation (never a popup — Safari blocks WebAuthn in
+cross-origin popups), and the fragment parks in the instance origin's
+`localStorage` (nonce-stamped, expiring), not `sessionStorage`, so a mobile
+OAuth app-switch can't orphan it. On return the welcome card renders
+caveat-free, and after the claim the session auto-saves to the account via a
+hands-free `/enroll` bounce (`payload.auto` — the live-session fast path
+only). Decision logic: `web/static/prejoin.js`; the home-side return guard:
+`home/web/static/joinreturn.js`. Design:
+`docs/superpowers/specs/2026-07-31-signup-first-join-design.md`.
+
**Signed-out server root** (e.g. visiting `chat.paulca.com` with no session) is
currently a dead end. It becomes the same card: **Restore with passkey** /
**Open in the app** / *"I have an invite link"*. During restore the instance
diff --git a/docs/planet-scale-guide/deploys.html b/docs/planet-scale-guide/deploys.html
index 9908898..60a8c93 100644
--- a/docs/planet-scale-guide/deploys.html
+++ b/docs/planet-scale-guide/deploys.html
@@ -70,7 +70,7 @@
</ol>
<h2 id="manual-deploy">A manual deploy, end to end: build, stage, canary, promote</h2>
-<p>This is the full ceremony for shipping a build you compiled yourself. It is the procedure behind every &ldquo;deploy the new version&rdquo; task, and each step exists for a reason given alongside it. <span class="badge live">running today</span> The repository&rsquo;s operator surface wraps the whole ceremony: <code>./deploy/ops canary create|deploy|destroy &lt;slug&gt;</code> runs build&thinsp;&rarr;&thinsp;stage&thinsp;&rarr;&thinsp;pinned-deploy for a per-session test instance (and the release path is <code>./deploy/ops publish / promote / ship</code>) — the steps below are what those verbs do, worth understanding even when a verb runs them for you.</p>
+<p>This is the full ceremony for shipping a build you compiled yourself. It is the procedure behind every &ldquo;deploy the new version&rdquo; task, and each step exists for a reason given alongside it. <span class="badge live">running today</span> The repository&rsquo;s operator surface wraps the whole ceremony: <code>./deploy/ops canary create|deploy|destroy &lt;slug&gt;</code> runs build&thinsp;&rarr;&thinsp;stage&thinsp;&rarr;&thinsp;pinned-deploy for a per-session test instance (and the release path is <code>./deploy/ops publish / promote / ship</code>) — the steps below are what those verbs do, worth understanding even when a verb runs them for you. Dev-mesh canaries are created pointing at the <em>dev</em> home server (<code>--home-origin https://home.dev.elevenmessenger.com</code>, override via <code>LCHAT_CANARY_HOME_ORIGIN</code>); the deliberate prod exception keeps the compiled production default.</p>
<ol class="procedure">
<li><p class="step-title">Build the Linux binary on your own machine, stamped with its version.</p>
<figure class="cmd">
diff --git a/docs/superpowers/plans/2026-07-31-signup-first-join.md b/docs/superpowers/plans/2026-07-31-signup-first-join.md
new file mode 100644
index 0000000..04bf6cb
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-31-signup-first-join.md
@@ -0,0 +1,622 @@
+# 🤖 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.
diff --git a/docs/superpowers/specs/2026-07-31-signup-first-join-design.md b/docs/superpowers/specs/2026-07-31-signup-first-join-design.md
new file mode 100644
index 0000000..fb96790
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-31-signup-first-join-design.md
@@ -0,0 +1,128 @@
+# 🤖 Signup-first join page — design
+
+**Date:** 2026-07-31 · **Status:** approved by Paul · **Scope:** web join page
+(native follows as a stated follow-up)
+
+## What changes
+
+Today a joiner opening `/join/<name>#<secret>` sees the app/web choice first,
+claims an invite with no account, and is asked to sign in only afterwards —
+by Eleven's welcome conversation, then the fragility banner. This flips the
+order: the **first screen is signup/sign-in**, and by the time the joiner
+reaches the continue-on-web / continue-in-the-app choice they already have an
+Eleven account and passkey. This is the golden path
+`docs/onboarding-and-home-server.md` §1 designed but never built.
+
+The account gate is **offered first, skippable** — a quiet "continue without
+an account" escape preserves the zero-friction join; skippers fall through to
+today's exact flow, including Eleven's ask and the fragility banner.
+
+## 1. The lead card
+
+New first screen for fresh joiners — all fresh-person shapes: generic
+invites, DM/user invites, and setup (new-space) invites — rendered by
+`join.js` from a new template in `join.html`:
+
+- Branded like today's welcome card (space avatar + name via the existing
+ sealed-branding machinery; plaintext branding fallback).
+- Copy: "You've been invited to *(space)*" plus one explainer line — your
+ sign-in makes your access portable to any device.
+- Primary: **Continue with your Eleven sign-in** → same-tab round-trip to the
+ home server (§3).
+- Quiet link: **Continue without an account** → reveals today's welcome card
+ unchanged (Open in the app / Get the app / Continue in the browser, with
+ the browser-only caveat).
+
+"All the sign-in options" are satisfied by landing on home's existing doors
+surface (smart single door — last-used else platform default — plus "Other
+ways to sign in": passkey, Apple, Google, Microsoft, GitHub, email code). The
+lead card links to that surface; it does not rebuild it.
+
+## 2. Who never sees the lead card
+
+These paths fall through to today's behaviour untouched:
+
+- Add-device links (`payload.priv`) — the person already has an identity.
+- Already-a-member adopt paths (all shapes).
+- The native embed form path (`window.__lchatEmbed`) and the Mac webview.
+- Dead/consumed invites — the existing error card (with its sign-in door).
+- Instances with no home server configured (`__HOME_ORIGIN__` empty —
+ self-hosters).
+
+## 3. The home-side pre-join intent
+
+Home's sign-in page (`home/`) grows a lightweight pre-join mode:
+
+- Query params: `?return=<url>` plus the instance host for display (host and
+ group name are plaintext branding by design — CLAUDE.md § branding).
+- Any door works. After the account exists **and a passkey is saved**, home
+ redirects to the return URL. An account that already exists in this browser
+ session completes immediately (the shipped session-reuse behaviour,
+ `d2c4092`).
+- **Return-URL guard:** must be `https` and path `/join/…`. Instance hosts
+ are arbitrary custom domains, so no host allowlist is possible; home
+ displays the destination host so the person sees where they're being sent.
+ This is post-auth navigation, not a credential redirect, so the residual
+ open-redirect risk is display-mitigated, not allowlist-mitigated.
+
+## 4. The invite secret never travels
+
+- Before navigating to home, `join.js` stashes the full original `#fragment`
+ in **instance-origin localStorage**, keyed by invite id, with an expiry,
+ cleared on restore. localStorage (not sessionStorage) so a mobile OAuth
+ app-switch that returns in a fresh tab can't orphan the secret. Lingering
+ risk is bounded: entries expire, and the invite is one-time anyway.
+- The `return` URL carries **no fragment**. On return, `join.js` restores the
+ fragment from the stash and cleans the URL (`history.replaceState`).
+- The home origin never sees the invite secret, preserving the invariant
+ that fragments never reach any server and the secret stays on the instance
+ origin.
+
+## 5. Return: the caveat-free choice card
+
+Back on the join page with a signed-in marker (query flag from home, cleaned
+after read):
+
+- The joiner sees the **same web/app choice**, but the "this browser only"
+ caveat is replaced with a positive line: "Your access is saved to your
+ Eleven account — you can sign in from any device."
+- **Continue in the browser** claims exactly as today — keypair, deferred
+ claim, bind-MAC (the claim-on-button-press invariant is untouched) — then,
+ before `location.href = "/"`, `join.js` auto-runs the existing `/enroll`
+ round-trip (`launchEnrol` machinery). Against the live home session this
+ completes instantly with no second ceremony, so the space is genuinely
+ saved to the account before the joiner lands in the chat.
+- **Open in the app** keeps today's `elevenmessenger://add` scheme handoff.
+ The vault-first handoff ("the space is already in your account — just sign
+ in in the app") is stated **phase 2**, because it changes invite-consumption
+ timing (claim-at-signup) and touches native sign-in.
+
+## 6. Eleven stands down
+
+- The welcome flow's `access` step (`web/static/welcome.flow.js`) gains an
+ "already saved" skip condition alongside its existing `native` /
+ `canEnrol` skips; likewise the `finish` step's "you haven't signed in yet"
+ warning and chip.
+- The fragility banner never arms for a saved session (existing behaviour —
+ verify, don't rebuild).
+
+## 7. Testing
+
+- Pull the which-card-for-which-state decision (link shape × home-origin
+ presence × signed-in marker × embed) out of DOM code into a pure helper
+ (`ui.js` or a new module) with Node tests, per the testable-module
+ convention.
+- Go test on home for the `?return` guard (https-only, `/join/` path only).
+- `node --check` on touched JS; `npm test` + `go test ./...` green.
+- Manual verification per the developer's recorded preference
+ (`CLAUDE.local.md`; ask if unrecorded).
+- Changelog entry (user-visible web change).
+
+## Out of scope (stated)
+
+- Native (iOS/Mac) direct-join restructure — follow-up, building on
+ `SignInWithEleven`.
+- Claim-at-signup + vault-first app handoff — phase 2.
+- Any change to invite consumption timing or the deferred-claim invariant.
+- The dead `homescreen-tpl` markup in `join.html` may be deleted in passing
+ if touched, but its removal is not a goal of this change.
diff --git a/home/test/joinreturn.test.mjs b/home/test/joinreturn.test.mjs
new file mode 100644
index 0000000..63f73c2
--- /dev/null
+++ b/home/test/joinreturn.test.mjs
@@ -0,0 +1,39 @@
+// 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), "");
+});
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
index 675d999..63bc891 100644
--- a/home/web/static/enroll.js
+++ b/home/web/static/enroll.js
@@ -414,4 +414,16 @@ if (payload && !window.PublicKeyCredential) {
back.textContent = "Cancel";
back.addEventListener("click", () => goBack(false));
$("actions").append(back);
+
+ // 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"));
+ }
}
diff --git a/home/web/static/home.js b/home/web/static/home.js
index ed2ea8d..4d6f932 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -36,6 +36,7 @@ import {
addTombstone, clearTombstone, mergeRemoved,
} from "/static/serverlist.js";
import { mountFader } from "/static/fader.js";
+import { parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash } from "/static/joinreturn.js";
import { startHomeLoader, finishHomeLoader } from "/static/bootscene.js";
import { BANDS, bandName, bandsOffered, tierForBand, regionCurrency, approxLocal, capNote } from "/static/fx.js";
@@ -168,6 +169,31 @@ let bandLink = (() => {
// the fragment through sign-in — a demo needs no account, so its claimant
// often has to create their sign-in first, and the claim resumes after.
let claimLinkHost = ((location.hash.match(/^#claim=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase();
+// 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;
+}
// Ceremony options are pre-fetched so the button click reaches
// navigator.credentials.* synchronously — Safari drops the user activation
@@ -2477,6 +2503,7 @@ function canCreateSpace() {
}
function showVault() {
+ if (consumeJoinReturn()) return; // pre-join sign-in: back to the join page, not the list
$("signed-out").classList.add("hidden");
$("reassure").classList.add("hidden");
resetEmailDoor(); // arriving from the email door hides it
@@ -4074,6 +4101,12 @@ if (claimLinkHost && !(state.token && state.dek)) {
setStatus("Sign in to keep your demo space.", "");
}
+// 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.", "");
+}
+
if (window.PublicKeyCredential) {
// The passkey controls get pre-fetched ceremony options, so a click reaches
// navigator.credentials.* inside the gesture (Safari activation). A browser
diff --git a/home/web/static/joinreturn.js b/home/web/static/joinreturn.js
new file mode 100644
index 0000000..bb958be
--- /dev/null
+++ b/home/web/static/joinreturn.js
@@ -0,0 +1,44 @@
+// 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 : "";
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
index 33b2284..4b37670 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
@@ -72,7 +72,7 @@
"label": "Signed in — your access is safe",
"value": "passkey"
},
- "sayIfNew": "Great — welcome back. 🔒 You're signed in, so your access is safe. Let's continue.",
+ "sayIfNew": "Great — you're signed in, so your access is safe. 🔒 Let's continue.",
"goto": "name"
},
{
diff --git a/package.json b/package.json
index 4aaaa19..b8abb38 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs"
}
}
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index 1246a5f..d65924d 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -786,7 +786,7 @@ await test("welcome: passkey-return leg — richer ack, no generic re-greet, que
// access entry supplies the richer ack instead, and the passkey question
// never renders.
assert.equal(h.transcript.some((t) => /^Welcome back — let's pick up/.test(t.text)), false);
- assert.ok(h.transcript.find((t) => /^Great — welcome back\. 🔒/.test(t.text)));
+ assert.ok(h.transcript.find((t) => /^Great — you're signed in/.test(t.text)));
assert.equal(h.transcript.some((t) => /Sign in with Eleven and your access is safe/.test(t.text)), false);
assert.deepEqual(h.records[0], { step: "access", label: "Signed in — your access is safe", value: "passkey" });
assert.ok(h.transcript.find((t) => t.extra.key === "q-name"));
@@ -828,7 +828,7 @@ await test("welcome: enrolment confirmed while on access-waiting settles through
await tick();
assert.deepEqual(h.records.find((r) => r.step === "access"),
{ step: "access", label: "Signed in — your access is safe", value: "passkey" });
- assert.ok(h.transcript.find((t) => /^Great — welcome back\. 🔒/.test(t.text)));
+ assert.ok(h.transcript.find((t) => /^Great — you're signed in/.test(t.text)));
assert.ok(h.transcript.find((t) => t.extra.key === "q-name"), "carried on to the name step");
});
diff --git a/test/prejoin.test.mjs b/test/prejoin.test.mjs
new file mode 100644
index 0000000..6a55b4b
--- /dev/null
+++ b/test/prejoin.test.mjs
@@ -0,0 +1,59 @@
+// 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"));
diff --git a/web/pages/join.html b/web/pages/join.html
index 406a238..66308a1 100644
--- a/web/pages/join.html
+++ b/web/pages/join.html
@@ -113,6 +113,24 @@
<div class="row"><button id="error-signin" class="secondary hidden">Already a member? Sign in</button></div>
</template>
+<!-- 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>
+
<!-- The welcome card (deferred claim — see docs/onboarding-and-home-server.md).
Nothing is claimed until a button is pressed; the one-time secret stays in
the URL fragment. data-store-url is per-instance config (LCHAT_APP_STORE_URL),
diff --git a/web/static/backup.js b/web/static/backup.js
index 2f8ef7f..6e91b97 100644
--- a/web/static/backup.js
+++ b/web/static/backup.js
@@ -238,7 +238,7 @@ function renderBanner(el, session) {
// here on success. A one-time nonce (stored locally, echoed in the return
// fragment) makes that return signal unforgeable: another site can't send you
// back with a fake success marker to suppress the backup reminder.
-export function launchEnrol(session) {
+export function launchEnrol(session, opts = {}) {
const home = window.__homeOrigin;
if (!home) return;
const native = isNativeWrapper();
@@ -256,6 +256,9 @@ export function launchEnrol(session) {
nonce, // echoed back in the return fragment to prove the redirect is genuine
ret: location.origin, // where /enroll sends the user back to (web only)
native: stay, // stay mode: /enroll ends on a "done" screen, no redirect back
+ // 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,
entry: {
url: location.origin, // the instance being enrolled
name: groupName(), // best-effort, for the confirmation UI
diff --git a/web/static/join.js b/web/static/join.js
index b07160e..aa1ecf4 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -12,9 +12,12 @@ import {
deriveInvite,
bindMac,
wrapThreadKey,
+ bytesToB64url,
} from "/static/crypto.js";
import { parseInviteFragment } from "/static/invite.js";
import { appSchemeFrom } from "/static/ui.js";
+import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL } from "/static/prejoin.js";
+import { launchEnrol } from "/static/backup.js";
const SESSION_KEY = "lchat_session";
const card = document.getElementById("card");
@@ -44,7 +47,24 @@ function showCard() {
const isStandalone =
window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
-const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1));
+// 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);
// ?src=home marks a link opened from the person's Eleven sign-in page (the
// create-a-space flow). Remember it on this origin so the setup conversation's
@@ -92,6 +112,9 @@ let inviteBindKey = "";
let serverHasAvatar = false;
let serverAvatarAlt = "";
let serverPhotoURL = "/api/server-avatar";
+// The sealed branding name, when this link's fragment can open it — the lead
+// card's h1 was server-baked with the generic fallback and needs the real name.
+let brandingName = "";
async function start() {
let id, wrapKey, info;
@@ -143,6 +166,7 @@ async function start() {
const bKey = await deriveBrandingKey(fragB, info.branding_salt);
const branding = await decrypt(bKey, info.branding_blob);
if (branding.name) {
+ brandingName = branding.name;
document.title = branding.name;
// The h1 was server-baked with the generic fallback (the server can't
// read the sealed name); the standard join card never re-sets it, so
@@ -317,8 +341,19 @@ function markWelcomePending(suggested) {
let cohortName = ""; // cohort invite: the level the joiner will be part of
// `spaceDesc` is the space's admin-set description (from the invite lookup).
-function showWelcome(kind, onContinue, spaceDesc = "") {
+function showWelcome(kind, onContinue, spaceDesc = "", opts = {}) {
if (window.__lchatEmbed) return onContinue();
+ // 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);
+ }
card.classList.add("onboard");
card.classList.remove("status");
card.innerHTML = "";
@@ -340,6 +375,13 @@ function showWelcome(kind, onContinue, spaceDesc = "") {
const sub = card.querySelector("#welcome-sub");
if (sub) sub.after(c);
}
+ // 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.";
+ }
if (kind === "device") {
// document.title carries the substituted group name.
card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`;
@@ -391,6 +433,52 @@ function showWelcome(kind, onContinue, spaceDesc = "") {
};
}
+// 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 });
+}
+
async function finish(id, name, pub, priv, wrappedSelf = "") {
let claim;
try {
@@ -442,6 +530,14 @@ async function finish(id, name, pub, priv, wrappedSelf = "") {
} catch {}
return;
}
+ // 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 });
+ }
await plugIn();
location.href = "/"; // drop the fragment from the URL bar
}
diff --git a/web/static/prejoin.js b/web/static/prejoin.js
new file mode 100644
index 0000000..a8012a5
--- /dev/null
+++ b/web/static/prejoin.js
@@ -0,0 +1,51 @@
+// 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);
+}
diff --git a/web/static/welcome.flow.js b/web/static/welcome.flow.js
index 88d07d4..db53ec5 100644
--- a/web/static/welcome.flow.js
+++ b/web/static/welcome.flow.js
@@ -50,7 +50,10 @@ export default {
recordIfNew: { label: "Signed in — your access is safe", value: "passkey" },
// Just back from the sign-in ceremony: acknowledge it worked, then
// move on — a separate bubble, so the ack lands before the question.
- sayIfNew: "Great — welcome back. 🔒 You're signed in, so your access is safe. Let's continue.",
+ // Covers BOTH arrivals: the mid-conversation return leg AND a joiner
+ // who signed up before the conversation started (signup-first join) —
+ // so no "welcome back", which reads wrong when nobody left.
+ sayIfNew: "Great — you're signed in, so your access is safe. 🔒 Let's continue.",
goto: "name",
},
// No home server to sign in with (self-hosted): the ask would be a