| 1 | # π€ Signup-First Join Page Implementation Plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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`. |
| 6 | |
| 7 | **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. |
| 8 | |
| 9 | **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. |
| 10 | |
| 11 | ## Global Constraints |
| 12 | |
| 13 | - Never `innerHTML` untrusted content; build DOM/`textContent` (static in-code strings OK). |
| 14 | - The invite fragment/secret must NEVER reach any server or the home origin. |
| 15 | - The deferred-claim invariant is untouched: nothing claims until a button press. |
| 16 | - No floating toasts; all feedback inline. |
| 17 | - New prose/comments are π€-marked where file convention applies (Markdown docs get π€; JS comments unmarked as per existing style). |
| 18 | - `npm test` and `go test ./...` green; `node --check` every touched JS file. |
| 19 | - Changed `welcome.flow.js` must regenerate `ios/LChatCore/Sources/LChatCore/Flows/welcome.json` (drift-gated by `test/flow.test.mjs`). |
| 20 | - Commits prefixed by area: `web:`, `home:`, `docs:`. |
| 21 | |
| 22 | --- |
| 23 | |
| 24 | ### Task 1: `web/static/prejoin.js` β pure instance-side decision helpers |
| 25 | |
| 26 | **Files:** |
| 27 | - Create: `web/static/prejoin.js` |
| 28 | - Test: `test/prejoin.test.mjs` |
| 29 | - Modify: `package.json` (add test to the `test` script) |
| 30 | |
| 31 | **Interfaces:** |
| 32 | - 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. |
| 33 | |
| 34 | - [ ] **Step 1: Write the failing test** β `test/prejoin.test.mjs`: |
| 35 | |
| 36 | ```js |
| 37 | // Tests for the signup-first join page's pure decision helpers (prejoin.js). |
| 38 | import test from "node:test"; |
| 39 | import assert from "node:assert/strict"; |
| 40 | import { |
| 41 | PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL, |
| 42 | } from "../web/static/prejoin.js"; |
| 43 | |
| 44 | const HOME = "https://home.example.com"; |
| 45 | const base = { kind: "join", homeOrigin: HOME, embed: false, native: false, signedUp: false }; |
| 46 | |
| 47 | test("offerPrejoin: fresh join/setup kinds with a home server", () => { |
| 48 | assert.equal(offerPrejoin(base), true); |
| 49 | assert.equal(offerPrejoin({ ...base, kind: "setup" }), true); |
| 50 | }); |
| 51 | |
| 52 | test("offerPrejoin: never for established or unsupported contexts", () => { |
| 53 | for (const kind of ["device", "adopt", "adopt-admin"]) { |
| 54 | assert.equal(offerPrejoin({ ...base, kind }), false); |
| 55 | } |
| 56 | assert.equal(offerPrejoin({ ...base, homeOrigin: "" }), false); |
| 57 | assert.equal(offerPrejoin({ ...base, homeOrigin: "__HOME_ORIGIN__" }), false); |
| 58 | assert.equal(offerPrejoin({ ...base, embed: true }), false); |
| 59 | assert.equal(offerPrejoin({ ...base, native: true }), false); |
| 60 | assert.equal(offerPrejoin({ ...base, signedUp: true }), false); |
| 61 | }); |
| 62 | |
| 63 | test("round trip: stash then read back on a genuine return", () => { |
| 64 | const now = 1_000_000; |
| 65 | const stored = prejoinStash({ frag: "e1.SECRET~Ann~bkey", nonce: "n0nce", now }); |
| 66 | const r = readPrejoinReturn({ search: "?su=n0nce", storedJSON: stored, now: now + 5000 }); |
| 67 | assert.deepEqual(r, { signedUp: true, frag: "e1.SECRET~Ann~bkey" }); |
| 68 | }); |
| 69 | |
| 70 | test("readPrejoinReturn: no marker, forged nonce, stale stash, clock skew", () => { |
| 71 | const now = 1_000_000; |
| 72 | const stored = prejoinStash({ frag: "f", nonce: "good", now }); |
| 73 | assert.deepEqual(readPrejoinReturn({ search: "", storedJSON: stored, now }), |
| 74 | { signedUp: false, frag: "" }); |
| 75 | assert.deepEqual(readPrejoinReturn({ search: "?su=evil", storedJSON: stored, now }), |
| 76 | { signedUp: false, frag: "" }); |
| 77 | assert.deepEqual(readPrejoinReturn({ search: "?su=good", storedJSON: stored, now: now + 61 * 60 * 1000 }), |
| 78 | { signedUp: false, frag: "" }); |
| 79 | assert.deepEqual(readPrejoinReturn({ search: "?su=good", storedJSON: stored, now: now - 1000 }), |
| 80 | { signedUp: false, frag: "" }); |
| 81 | assert.deepEqual(readPrejoinReturn({ search: "?su=good", storedJSON: "not json", now }), |
| 82 | { signedUp: false, frag: "" }); |
| 83 | assert.deepEqual(readPrejoinReturn({ search: "?su=good", storedJSON: "", now }), |
| 84 | { signedUp: false, frag: "" }); |
| 85 | }); |
| 86 | |
| 87 | test("homeSigninURL: return URL rides the fragment, encoded, no invite secret", () => { |
| 88 | const url = homeSigninURL(HOME, "https://chat.example.com", "/join/abc", "n+once"); |
| 89 | assert.equal(url, |
| 90 | HOME + "/#joinret=" + encodeURIComponent("https://chat.example.com/join/abc?su=" + encodeURIComponent("n+once"))); |
| 91 | assert.equal(homeSigninURL(HOME + "/", "https://c.example.com", "/join/x", "n"), |
| 92 | HOME + "/#joinret=" + encodeURIComponent("https://c.example.com/join/x?su=n")); |
| 93 | }); |
| 94 | |
| 95 | test("stash key is stable", () => assert.equal(PREJOIN_STASH_KEY, "lchat_prejoin")); |
| 96 | ``` |
| 97 | |
| 98 | - [ ] **Step 2: Run it to verify it fails**: `node test/prejoin.test.mjs` β expected: ERR_MODULE_NOT_FOUND for prejoin.js. |
| 99 | |
| 100 | - [ ] **Step 3: Implement `web/static/prejoin.js`:** |
| 101 | |
| 102 | ```js |
| 103 | // Pure decision helpers for the signup-first join page (docs/superpowers/specs/ |
| 104 | // 2026-07-31-signup-first-join-design.md): which links lead with the sign-in |
| 105 | // card, and how the round trip to the home server is stamped so a forged or |
| 106 | // stale ?su= marker never reads as "signed up". No DOM, no storage β join.js |
| 107 | // owns those β so this is Node-testable (test/prejoin.test.mjs). |
| 108 | |
| 109 | export const PREJOIN_STASH_KEY = "lchat_prejoin"; |
| 110 | |
| 111 | // A pre-join round trip should be minutes; an hour-old stash is an abandoned |
| 112 | // one β reading it as a fresh sign-up would put a stale "saved" promise on the |
| 113 | // welcome card. |
| 114 | const MAX_AGE_MS = 60 * 60 * 1000; |
| 115 | |
| 116 | // offerPrejoin: the lead card shows only to a FRESH person (join/setup kinds β |
| 117 | // device links and adopts already have an identity), only where a home server |
| 118 | // exists to sign into, and never in the native embed/wrapper (the apps own |
| 119 | // their sign-in). signedUp means the round trip already happened. |
| 120 | export function offerPrejoin({ kind, homeOrigin, embed, native, signedUp }) { |
| 121 | if (embed || native || signedUp) return false; |
| 122 | if (kind !== "join" && kind !== "setup") return false; |
| 123 | return typeof homeOrigin === "string" && homeOrigin.startsWith("http"); |
| 124 | } |
| 125 | |
| 126 | // prejoinStash packages what must survive the navigation away: the invite |
| 127 | // fragment (the secret NEVER travels to the home origin β it waits here, on |
| 128 | // the instance origin) and the nonce the return marker must echo. |
| 129 | export function prejoinStash({ frag, nonce, now }) { |
| 130 | return JSON.stringify({ v: 1, frag: String(frag || ""), nonce: String(nonce || ""), at: now }); |
| 131 | } |
| 132 | |
| 133 | // readPrejoinReturn: ?su=<nonce> marks a return from the home sign-in. It |
| 134 | // counts only when the nonce matches a fresh stash β anyone can type ?su=1, |
| 135 | // but only this browser holds the stash minted before leaving. |
| 136 | export function readPrejoinReturn({ search, storedJSON, now }) { |
| 137 | const none = { signedUp: false, frag: "" }; |
| 138 | const su = new URLSearchParams(search || "").get("su") || ""; |
| 139 | if (!su) return none; |
| 140 | let stash = null; |
| 141 | try { stash = JSON.parse(storedJSON || "null"); } catch { return none; } |
| 142 | if (!stash || typeof stash.nonce !== "string" || stash.nonce !== su) return none; |
| 143 | if (typeof stash.at !== "number" || now < stash.at || now - stash.at > MAX_AGE_MS) return none; |
| 144 | return { signedUp: true, frag: String(stash.frag || "") }; |
| 145 | } |
| 146 | |
| 147 | // homeSigninURL builds the door: the home page reads #joinret=<url> (a |
| 148 | // fragment, so the return URL never appears in home's server logs) and sends |
| 149 | // the person back to it once signed in. Only the nonce rides the return URL. |
| 150 | export function homeSigninURL(homeOrigin, origin, pathname, nonce) { |
| 151 | const ret = origin + pathname + "?su=" + encodeURIComponent(nonce); |
| 152 | return homeOrigin.replace(/\/$/, "") + "/#joinret=" + encodeURIComponent(ret); |
| 153 | } |
| 154 | ``` |
| 155 | |
| 156 | - [ ] **Step 4: Run tests to verify they pass**: `node test/prejoin.test.mjs` β expected: all pass. |
| 157 | |
| 158 | - [ ] **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. |
| 159 | |
| 160 | - [ ] **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"` |
| 161 | |
| 162 | --- |
| 163 | |
| 164 | ### Task 2: `home/web/static/joinreturn.js` β pure home-side return helpers |
| 165 | |
| 166 | **Files:** |
| 167 | - Create: `home/web/static/joinreturn.js` |
| 168 | - Test: `home/test/joinreturn.test.mjs` |
| 169 | - Modify: `package.json` (add test) |
| 170 | |
| 171 | **Interfaces:** |
| 172 | - Produces: `parseJoinReturn(hash) β string`, `validJoinReturn(url) β bool`, `stashJoinReturn(url, now) β string`, `readJoinReturnStash(json, now) β string`. Task 3 (home.js) consumes all. |
| 173 | |
| 174 | - [ ] **Step 1: Write the failing test** β `home/test/joinreturn.test.mjs`: |
| 175 | |
| 176 | ```js |
| 177 | // Tests for the pre-join sign-in return helpers (joinreturn.js) β the guard |
| 178 | // that decides where a signed-in person may be redirected back to. |
| 179 | import test from "node:test"; |
| 180 | import assert from "node:assert/strict"; |
| 181 | import { |
| 182 | parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash, |
| 183 | } from "../web/static/joinreturn.js"; |
| 184 | |
| 185 | const OK = "https://chat.example.com/join/abc?su=n1"; |
| 186 | |
| 187 | test("parseJoinReturn: the #joinret= fragment, decoded", () => { |
| 188 | assert.equal(parseJoinReturn("#joinret=" + encodeURIComponent(OK)), OK); |
| 189 | assert.equal(parseJoinReturn(""), ""); |
| 190 | assert.equal(parseJoinReturn("#claim=host"), ""); |
| 191 | assert.equal(parseJoinReturn("#joinret=%"), ""); // malformed encoding |
| 192 | }); |
| 193 | |
| 194 | test("validJoinReturn: https /join/ URLs only (http for localhost dev)", () => { |
| 195 | assert.equal(validJoinReturn(OK), true); |
| 196 | assert.equal(validJoinReturn("http://localhost:8080/join/abc?su=n"), true); |
| 197 | assert.equal(validJoinReturn("http://127.0.0.1:8080/join/abc"), true); |
| 198 | assert.equal(validJoinReturn("http://chat.example.com/join/abc"), false); |
| 199 | assert.equal(validJoinReturn("https://chat.example.com/other"), false); |
| 200 | assert.equal(validJoinReturn("https://u:p@chat.example.com/join/abc"), false); |
| 201 | assert.equal(validJoinReturn("javascript:alert(1)"), false); |
| 202 | assert.equal(validJoinReturn("not a url"), false); |
| 203 | }); |
| 204 | |
| 205 | test("stash round trip, expiry, tamper", () => { |
| 206 | const now = 5_000_000; |
| 207 | const s = stashJoinReturn(OK, now); |
| 208 | assert.equal(readJoinReturnStash(s, now + 1000), OK); |
| 209 | assert.equal(readJoinReturnStash(s, now + 61 * 60 * 1000), ""); |
| 210 | assert.equal(readJoinReturnStash(s, now - 1000), ""); |
| 211 | assert.equal(readJoinReturnStash("junk", now), ""); |
| 212 | assert.equal(readJoinReturnStash("", now), ""); |
| 213 | // A stash whose URL fails the guard reads as absent β validated on BOTH ends. |
| 214 | assert.equal(readJoinReturnStash(stashJoinReturn("https://evil.example.com/x", now), now), ""); |
| 215 | }); |
| 216 | ``` |
| 217 | |
| 218 | - [ ] **Step 2: Run to verify failure**: `node home/test/joinreturn.test.mjs` β expected: ERR_MODULE_NOT_FOUND. |
| 219 | |
| 220 | - [ ] **Step 3: Implement `home/web/static/joinreturn.js`:** |
| 221 | |
| 222 | ```js |
| 223 | // Pure helpers for the pre-join sign-in return (the signup-first join flow): |
| 224 | // a join page sends its visitor here with #joinret=<url>, and once signed in |
| 225 | // the first vault render sends them straight back. These helpers are the |
| 226 | // guard on that redirect β Node-tested (home/test/joinreturn.test.mjs). |
| 227 | |
| 228 | // A sign-in should take minutes; an hour-old return intent is abandoned, and |
| 229 | // honouring it would teleport a later sign-in in this tab somewhere unexpected. |
| 230 | const MAX_AGE_MS = 60 * 60 * 1000; |
| 231 | |
| 232 | // parseJoinReturn pulls the return URL out of "#joinret=<encoded url>". |
| 233 | export function parseJoinReturn(hash) { |
| 234 | const m = /^#joinret=(.+)$/.exec(hash || ""); |
| 235 | if (!m) return ""; |
| 236 | try { return decodeURIComponent(m[1]); } catch { return ""; } |
| 237 | } |
| 238 | |
| 239 | // validJoinReturn: only ever send a signed-in person to an https /join/ page |
| 240 | // (http allowed for localhost dev). Instance hosts are arbitrary custom |
| 241 | // domains, so no host allowlist is possible β the signed-out status line |
| 242 | // displays the destination host instead, and the path/scheme pin means the |
| 243 | // worst a crafted link can do is land someone on a join page they can close. |
| 244 | export function validJoinReturn(url) { |
| 245 | let u; |
| 246 | try { u = new URL(url); } catch { return false; } |
| 247 | const local = u.hostname === "localhost" || u.hostname === "127.0.0.1"; |
| 248 | if (u.protocol !== "https:" && !(u.protocol === "http:" && local)) return false; |
| 249 | if (u.username || u.password) return false; |
| 250 | return u.pathname.startsWith("/join/"); |
| 251 | } |
| 252 | |
| 253 | // The sessionStorage shape. Parked (not held in a variable) because the anchor |
| 254 | // doors (Apple/Google/β¦) leave this page and re-enter it β a fragment or a |
| 255 | // variable wouldn't survive the trip; tab-scoped sessionStorage does. |
| 256 | export function stashJoinReturn(url, now) { |
| 257 | return JSON.stringify({ v: 1, url: String(url), at: now }); |
| 258 | } |
| 259 | |
| 260 | export function readJoinReturnStash(json, now) { |
| 261 | let s = null; |
| 262 | try { s = JSON.parse(json || "null"); } catch { return ""; } |
| 263 | if (!s || typeof s.url !== "string" || typeof s.at !== "number") return ""; |
| 264 | if (now < s.at || now - s.at > MAX_AGE_MS) return ""; |
| 265 | return validJoinReturn(s.url) ? s.url : ""; |
| 266 | } |
| 267 | ``` |
| 268 | |
| 269 | - [ ] **Step 4: Run tests**: `node home/test/joinreturn.test.mjs` β expected: pass. |
| 270 | |
| 271 | - [ ] **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. |
| 272 | |
| 273 | - [ ] **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"` |
| 274 | |
| 275 | --- |
| 276 | |
| 277 | ### Task 3: home.js β park the return intent, redirect back after sign-in |
| 278 | |
| 279 | **Files:** |
| 280 | - 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) |
| 281 | |
| 282 | **Interfaces:** |
| 283 | - Consumes: Task 2's four functions. |
| 284 | - Produces: the behaviour Task 4 relies on β home lands back on `/join/<label>?su=<nonce>` once signed in (instantly when already signed in). |
| 285 | |
| 286 | - [ ] **Step 1: Add the import** at the top of `home/web/static/home.js`, alongside its existing `/static/` imports: |
| 287 | |
| 288 | ```js |
| 289 | import { parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash } from "/static/joinreturn.js"; |
| 290 | ``` |
| 291 | |
| 292 | - [ ] **Step 2: Boot-parse the fragment** β insert directly after the `claimLinkHost` declaration (~line 170): |
| 293 | |
| 294 | ```js |
| 295 | // A join page sends its visitor here to sign in FIRST (#joinret=<url> β the |
| 296 | // signup-first join flow). The return URL parks in sessionStorage so it |
| 297 | // survives the anchor doors' round trips (Apple/Google leave this page and |
| 298 | // come back via their trampolines, dropping the hash), and the first vault |
| 299 | // render after sign-in sends the person straight back to finish joining |
| 300 | // instead of showing the space list. Guarded both ends: joinreturn.js pins |
| 301 | // the destination to an https /join/ path. |
| 302 | { |
| 303 | const jr = parseJoinReturn(location.hash); |
| 304 | if (jr && validJoinReturn(jr)) { |
| 305 | try { sessionStorage.setItem("home_join_return", stashJoinReturn(jr, Date.now())); } catch { /* no storage β plain sign-in */ } |
| 306 | history.replaceState(null, "", location.pathname + location.search); |
| 307 | } |
| 308 | } |
| 309 | function joinReturnURL() { |
| 310 | try { return readJoinReturnStash(sessionStorage.getItem("home_join_return") || "", Date.now()); } catch { return ""; } |
| 311 | } |
| 312 | // consumeJoinReturn: the redirect back. True = navigating; callers stop rendering. |
| 313 | function consumeJoinReturn() { |
| 314 | const url = joinReturnURL(); |
| 315 | if (!url) return false; |
| 316 | try { sessionStorage.removeItem("home_join_return"); } catch {} |
| 317 | location.replace(url); |
| 318 | return true; |
| 319 | } |
| 320 | ``` |
| 321 | |
| 322 | - [ ] **Step 3: Redirect on the first vault render** β first line of `showVault()`: |
| 323 | |
| 324 | ```js |
| 325 | function showVault() { |
| 326 | if (consumeJoinReturn()) return; // pre-join sign-in: back to the join page, not the list |
| 327 | ``` |
| 328 | |
| 329 | - [ ] **Step 4: Say why you're looking at a sign-in page** β next to the `claimLinkHost` status block (~line 4073): |
| 330 | |
| 331 | ```js |
| 332 | // Arriving from a join page's sign-in-first card while signed out: say where |
| 333 | // this leads, host included (the display half of the redirect guard). |
| 334 | if (joinReturnURL() && !(state.token && state.dek)) { |
| 335 | setStatus("Sign in, and you'll head straight back to " + hostOf(joinReturnURL()) + " to finish joining.", ""); |
| 336 | } |
| 337 | ``` |
| 338 | |
| 339 | - [ ] **Step 5: Verify**: `node --check home/web/static/home.js` β clean; `npm test` β green. |
| 340 | |
| 341 | - [ ] **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"` |
| 342 | |
| 343 | --- |
| 344 | |
| 345 | ### Task 4: join page β the lead card, fragment stash/restore, caveat-free return |
| 346 | |
| 347 | **Files:** |
| 348 | - Modify: `web/pages/join.html` (new `prejoin-tpl` template) |
| 349 | - Modify: `web/static/join.js` (imports, fragment restore, `brandingName`, `showWelcome` hook + caveat swap, new `showPrejoin`) |
| 350 | |
| 351 | **Interfaces:** |
| 352 | - Consumes: Task 1's helpers; `bytesToB64url` from `crypto.js`. |
| 353 | - Produces: module-level `prejoin` (`{signedUp, frag}`) that Task 5's `finish()` change reads. |
| 354 | |
| 355 | - [ ] **Step 1: Add the template** to `web/pages/join.html`, directly above `welcome-tpl` (~line 116): |
| 356 | |
| 357 | ```html |
| 358 | <!-- The signup-first lead card (docs/superpowers/specs/2026-07-31-signup-first- |
| 359 | join-design.md): fresh joiners see the sign-in door BEFORE the app/web |
| 360 | choice, so their access is saved to their account from the start. Skippable |
| 361 | β "Continue without an account" reveals the classic welcome card. --> |
| 362 | <template id="prejoin-tpl"> |
| 363 | <img id="prejoin-photo" class="onboard-photo hidden" alt="" /> |
| 364 | <div class="onboard-icon" id="prejoin-icon">π¬</div> |
| 365 | <h1 id="prejoin-title">You're invited to __GROUP_NAME__</h1> |
| 366 | <p id="prejoin-sub">Private, end-to-end encrypted chat.</p> |
| 367 | <p id="prejoin-desc" class="welcome-desc hidden"></p> |
| 368 | <div class="row"><button id="prejoin-signin">Continue with your Eleven sign-in</button></div> |
| 369 | <div class="row"><button id="prejoin-skip" class="secondary">Continue without an account</button></div> |
| 370 | <p class="welcome-caveat"> |
| 371 | Signing in first keeps your access safe β get back into this space from any |
| 372 | device, with nothing to remember. |
| 373 | </p> |
| 374 | </template> |
| 375 | ``` |
| 376 | |
| 377 | - [ ] **Step 2: join.js β imports and the return leg.** Add to the imports: `bytesToB64url` in the `crypto.js` list, plus: |
| 378 | |
| 379 | ```js |
| 380 | import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL } from "/static/prejoin.js"; |
| 381 | import { launchEnrol } from "/static/backup.js"; |
| 382 | ``` |
| 383 | |
| 384 | Replace line 47 (`const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1));`) with: |
| 385 | |
| 386 | ```js |
| 387 | // The pre-join sign-in round trip returns here as /join/<label>?su=<nonce> |
| 388 | // with NO fragment β the invite secret parked in this origin's localStorage |
| 389 | // before leaving (it never travels to the home origin). Verify the nonce, |
| 390 | // recover the fragment (restoring it to the URL so "Open in the app" still |
| 391 | // hands over a complete link), and scrub the marker. |
| 392 | const prejoin = (() => { |
| 393 | let stored = ""; |
| 394 | try { stored = localStorage.getItem(PREJOIN_STASH_KEY) || ""; } catch {} |
| 395 | const r = readPrejoinReturn({ search: location.search, storedJSON: stored, now: Date.now() }); |
| 396 | if (r.signedUp) { |
| 397 | try { localStorage.removeItem(PREJOIN_STASH_KEY); } catch {} |
| 398 | try { |
| 399 | if (!location.hash && r.frag) history.replaceState(null, "", location.pathname + "#" + r.frag); |
| 400 | } catch {} |
| 401 | } |
| 402 | return r; |
| 403 | })(); |
| 404 | const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1) || prejoin.frag); |
| 405 | ``` |
| 406 | |
| 407 | - [ ] **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. |
| 408 | |
| 409 | - [ ] **Step 4: Hook `showWelcome`.** Change its signature to `function showWelcome(kind, onContinue, spaceDesc = "", opts = {})` and insert after the `if (window.__lchatEmbed) return onContinue();` line: |
| 410 | |
| 411 | ```js |
| 412 | // Signup-first: a fresh joiner leads with the sign-in door, not the app/web |
| 413 | // choice (skippable; established identities and native contexts never see it). |
| 414 | if (!opts.skipPrejoin && offerPrejoin({ |
| 415 | kind, |
| 416 | homeOrigin: window.__homeOrigin || "", |
| 417 | embed: !!window.__lchatEmbed, |
| 418 | native: document.documentElement.classList.contains("native"), |
| 419 | signedUp: prejoin.signedUp, |
| 420 | })) { |
| 421 | return showPrejoin(kind, onContinue, spaceDesc); |
| 422 | } |
| 423 | ``` |
| 424 | |
| 425 | Then, after the cohort block (`if (cohortName && kind !== "device") {...}`, before the `if (kind === "device")` branch), insert the caveat swap: |
| 426 | |
| 427 | ```js |
| 428 | // Back from the pre-join sign-in: the browser-only caveat is no longer true β |
| 429 | // their access rides their account (finish() saves the session to it). |
| 430 | if (prejoin.signedUp) { |
| 431 | const cav = card.querySelector("#welcome-caveat"); |
| 432 | if (cav) cav.textContent = |
| 433 | "Your access will be saved to your Eleven account β you can get back in from any device."; |
| 434 | } |
| 435 | ``` |
| 436 | |
| 437 | - [ ] **Step 5: Add `showPrejoin`** after `showWelcome` (~line 393): |
| 438 | |
| 439 | ```js |
| 440 | // The signup-first lead card: sign in with Eleven BEFORE anything is claimed. |
| 441 | // The tap parks the invite fragment (and a fresh nonce) on THIS origin and |
| 442 | // heads to the home server's doors; home returns to /join/<label>?su=<nonce>. |
| 443 | // Skipping falls through to the classic welcome card β zero-friction stays. |
| 444 | function showPrejoin(kind, onContinue, spaceDesc) { |
| 445 | card.classList.add("onboard"); |
| 446 | card.classList.remove("status"); |
| 447 | card.innerHTML = ""; |
| 448 | showCard(); |
| 449 | card.appendChild(document.getElementById("prejoin-tpl").content.cloneNode(true)); |
| 450 | applyServerPhoto(card); |
| 451 | // The h1 was server-baked with the generic fallback on sealed instances. |
| 452 | if (brandingName) card.querySelector("#prejoin-title").textContent = `You're invited to ${brandingName}`; |
| 453 | if (kind === "setup") { |
| 454 | card.querySelector("#prejoin-title").textContent = "Welcome to your new space"; |
| 455 | card.querySelector("#prejoin-sub").textContent = |
| 456 | "This one-time link makes you its administrator. Sign in first, and your access is saved from the start."; |
| 457 | } |
| 458 | if (spaceDesc) { |
| 459 | const d = card.querySelector("#prejoin-desc"); |
| 460 | d.textContent = spaceDesc.slice(0, 600); |
| 461 | d.classList.remove("hidden"); |
| 462 | } |
| 463 | if (cohortName) { |
| 464 | const c = document.createElement("p"); |
| 465 | c.className = "sub"; |
| 466 | c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120); |
| 467 | card.querySelector("#prejoin-sub").after(c); |
| 468 | } |
| 469 | card.querySelector("#prejoin-signin").onclick = () => { |
| 470 | const b = new Uint8Array(16); |
| 471 | crypto.getRandomValues(b); |
| 472 | const nonce = bytesToB64url(b); |
| 473 | try { |
| 474 | localStorage.setItem(PREJOIN_STASH_KEY, prejoinStash({ frag: location.hash.slice(1), nonce, now: Date.now() })); |
| 475 | } catch { |
| 476 | // No storage: the fragment can't survive the round trip β fall through to |
| 477 | // the classic card rather than strand the secret. |
| 478 | return showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true }); |
| 479 | } |
| 480 | location.assign(homeSigninURL(window.__homeOrigin, location.origin, location.pathname, nonce)); |
| 481 | }; |
| 482 | card.querySelector("#prejoin-skip").onclick = () => |
| 483 | showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true }); |
| 484 | } |
| 485 | ``` |
| 486 | |
| 487 | - [ ] **Step 6: Verify**: `node --check web/static/join.js` β clean; `npm test` β green. |
| 488 | |
| 489 | - [ ] **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"` |
| 490 | |
| 491 | --- |
| 492 | |
| 493 | ### Task 5: post-claim auto-save β `finish()` bounce + `launchEnrol` `auto` + enroll.js fast path |
| 494 | |
| 495 | **Files:** |
| 496 | - Modify: `web/static/join.js` (`finish()`, ~line 429-447) |
| 497 | - Modify: `web/static/backup.js` (`launchEnrol` signature, ~line 241) |
| 498 | - Modify: `home/web/static/enroll.js` (boot block, ~line 403) |
| 499 | |
| 500 | **Interfaces:** |
| 501 | - Consumes: Task 4's `prejoin`, backup.js's `launchEnrol`, enroll.js's existing `trySessionReuse`/`applyEntry`/`succeed`. |
| 502 | - Produces: `launchEnrol(session, opts)` where `opts.auto` sets `payload.auto` β existing callers (shell.js and flows via the `savePasskey` verb) are unaffected. |
| 503 | |
| 504 | - [ ] **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): |
| 505 | |
| 506 | ```js |
| 507 | // auto: a post-claim handoff from the signup-first join β /enroll tries its |
| 508 | // live-session fast path without a tap (and only that; see enroll.js). |
| 509 | auto: !!opts.auto, |
| 510 | ``` |
| 511 | |
| 512 | - [ ] **Step 2: join.js β the bounce.** In `finish()`, between the `if (window.__lchatEmbed) {...return;}` block and `await plugIn();`, insert: |
| 513 | |
| 514 | ```js |
| 515 | // Signed up before joining: save this brand-new session to the account NOW, |
| 516 | // via the /enroll round trip. The home-tab session from the sign-in moments |
| 517 | // ago is still live, so enrolment completes hands-free (payload.auto) and |
| 518 | // lands back at "/" with the standard nonce marker β the joiner arrives in |
| 519 | // the chat already backed up, and Eleven never has to ask. |
| 520 | if (prejoin.signedUp && (window.__homeOrigin || "").startsWith("http")) { |
| 521 | return launchEnrol({ token: claim.token, name, userPub: pub, userPriv: priv }, { auto: true }); |
| 522 | } |
| 523 | ``` |
| 524 | |
| 525 | - [ ] **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: |
| 526 | |
| 527 | ```js |
| 528 | // A post-claim handoff from the signup-first join (payload.auto): the tab |
| 529 | // signed in at home moments ago, so try the live-session fast path without |
| 530 | // a tap. Auto NEVER runs a ceremony and never skips the replace guard β if |
| 531 | // the session can't complete the save silently, the normal card stands and |
| 532 | // the buttons above do their usual work. |
| 533 | if (payload.auto) { |
| 534 | disable(); |
| 535 | trySessionReuse() |
| 536 | .then((done) => { if (!done) enable(); }) |
| 537 | .catch((e) => onError(e, "signin")); |
| 538 | } |
| 539 | ``` |
| 540 | |
| 541 | - [ ] **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). |
| 542 | |
| 543 | - [ ] **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"` |
| 544 | |
| 545 | --- |
| 546 | |
| 547 | ### Task 6: Eleven stands down β flow copy + regenerated native bundle |
| 548 | |
| 549 | **Files:** |
| 550 | - Modify: `web/static/welcome.flow.js` (~line 53, the access entry's `sayIfNew`) |
| 551 | - Modify: `test/flow.test.mjs` (lines ~789 and ~831, the pinned copy regexes) |
| 552 | - Regenerate: `ios/LChatCore/Sources/LChatCore/Flows/welcome.json` |
| 553 | |
| 554 | 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. |
| 555 | |
| 556 | - [ ] **Step 1: Update the pinned tests first** β in `test/flow.test.mjs`, change both occurrences of |
| 557 | |
| 558 | ```js |
| 559 | assert.ok(h.transcript.find((t) => /^Great β welcome back\. π/.test(t.text))); |
| 560 | ``` |
| 561 | |
| 562 | to |
| 563 | |
| 564 | ```js |
| 565 | assert.ok(h.transcript.find((t) => /^Great β you're signed in/.test(t.text))); |
| 566 | ``` |
| 567 | |
| 568 | - [ ] **Step 2: Run to verify failure**: `node test/flow.test.mjs` β expected: the two assertions fail (copy not yet changed). |
| 569 | |
| 570 | - [ ] **Step 3: Change the copy** β in `web/static/welcome.flow.js`, the access entry's `sayIfNew`: |
| 571 | |
| 572 | ```js |
| 573 | sayIfNew: "Great β you're signed in, so your access is safe. π Let's continue.", |
| 574 | ``` |
| 575 | |
| 576 | - [ ] **Step 4: Regenerate the native bundle**: |
| 577 | |
| 578 | ```bash |
| 579 | 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"); })' |
| 580 | ``` |
| 581 | |
| 582 | - [ ] **Step 5: Run tests**: `node test/flow.test.mjs` β expected: pass, including the native-bundle drift gate. Then `npm test` β green. |
| 583 | |
| 584 | - [ ] **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)"` |
| 585 | |
| 586 | --- |
| 587 | |
| 588 | ### Task 7: changelog + doc touch-up |
| 589 | |
| 590 | **Files:** |
| 591 | - Create: `changelog/2026-07-31-signup-first-join.md` |
| 592 | - Modify: `docs/onboarding-and-home-server.md` (Β§1 note that the sign-in-first golden path shipped) |
| 593 | |
| 594 | - [ ] **Step 1: Changelog entry** (member-facing, π€ on the first line): |
| 595 | |
| 596 | ```markdown |
| 597 | π€ Joining from an invite link now starts with your sign-in. Create (or use) |
| 598 | your Eleven sign-in first, and your access to the new space is saved to your |
| 599 | account before you even choose between browser and app β no way to lose your |
| 600 | way in. Prefer not to? "Continue without an account" keeps the familiar |
| 601 | one-tap join. |
| 602 | ``` |
| 603 | |
| 604 | - [ ] **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). |
| 605 | |
| 606 | - [ ] **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"` |
| 607 | |
| 608 | --- |
| 609 | |
| 610 | ### Task 8: full verification + ship |
| 611 | |
| 612 | - [ ] **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). |
| 613 | - [ ] **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` |
| 614 | - [ ] **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). |
| 615 | - [ ] **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. |
| 616 | |
| 617 | ## Self-review notes |
| 618 | |
| 619 | - 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. |
| 620 | - 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. |
| 621 | - 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. |
| 622 | - `?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. |
| 623 | |