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>
19 files changed,
+1150
−10
changelog/2026-07-31-signup-first-join.md+5 −0deploy/ops-canary.sh+14 −1docs/onboarding-and-home-server.md+14 −0docs/planet-scale-guide/deploys.html+1 −1docs/superpowers/plans/2026-07-31-signup-first-join.md+622 −0docs/superpowers/specs/2026-07-31-signup-first-join-design.md+128 −0home/test/joinreturn.test.mjs+39 −0home/web/static/enroll.js+12 −0home/web/static/home.js+33 −0home/web/static/joinreturn.js+44 −0ios/LChatCore/Sources/LChatCore/Flows/welcome.json+1 −1package.json+1 −1test/flow.test.mjs+2 −2test/prejoin.test.mjs+59 −0web/pages/join.html+18 −0web/static/backup.js+4 −1web/static/join.js+98 −2web/static/prejoin.js+51 −0web/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 “deploy the new version” task, and each step exists for a reason given alongside it. <span class="badge live">running today</span> The repository’s operator surface wraps the whole ceremony: <code>./deploy/ops canary create|deploy|destroy <slug></code> runs build → stage → 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 “deploy the new version” task, and each step exists for a reason given alongside it. <span class="badge live">running today</span> The repository’s operator surface wraps the whole ceremony: <code>./deploy/ops canary create|deploy|destroy <slug></code> runs build → stage → 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 |