eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

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).
38import test from "node:test";
39import assert from "node:assert/strict";
40import {
41 PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL,
42} from "../web/static/prejoin.js";
43
44const HOME = "https://home.example.com";
45const base = { kind: "join", homeOrigin: HOME, embed: false, native: false, signedUp: false };
46
47test("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
52test("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
63test("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
70test("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
87test("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
95test("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
109export 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.
114const 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.
120export 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.
129export 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.
136export 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.
150export 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.
179import test from "node:test";
180import assert from "node:assert/strict";
181import {
182 parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash,
183} from "../web/static/joinreturn.js";
184
185const OK = "https://chat.example.com/join/abc?su=n1";
186
187test("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
194test("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
205test("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.
230const MAX_AGE_MS = 60 * 60 * 1000;
231
232// parseJoinReturn pulls the return URL out of "#joinret=<encoded url>".
233export 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.
244export 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.
256export function stashJoinReturn(url, now) {
257 return JSON.stringify({ v: 1, url: String(url), at: now });
258}
259
260export 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
289import { 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}
309function 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.
313function 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
325function 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).
334if (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
380import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL } from "/static/prejoin.js";
381import { launchEnrol } from "/static/backup.js";
382```
383
384Replace 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.
392const 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})();
404const { 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
425Then, 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.
444function 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
554Why: 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
559assert.ok(h.transcript.find((t) => /^Great β€” welcome back\. πŸ”’/.test(t.text)));
560```
561
562to
563
564```js
565assert.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
579node -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)
598your Eleven sign-in first, and your access to the new space is saved to your
599account before you even choose between browser and app β€” no way to lose your
600way in. Prefer not to? "Continue without an account" keeps the familiar
601one-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