// 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. // // fromHome (the setup link's ?src=home) is the other established identity: // this person just created the space FROM their signed-in Eleven page, which // already saved it to their account — asking them to sign in again, one tap // after signing in, is the flow contradicting itself. They meet the sign-in // once more at the end, where it belongs: the setup conversation's keys-home // step, adding this space's key to the account they already have. export function offerPrejoin({ kind, homeOrigin, embed, native, signedUp, fromHome }) { if (embed || native || signedUp || fromHome) return false; if (kind !== "join" && kind !== "setup") return false; return typeof homeOrigin === "string" && homeOrigin.startsWith("http"); } // autoEnrolOnClaim: whether a just-claimed session should be pushed into the // person's Eleven account immediately, with no tap (launchEnrol's `auto`). // True for the two arrivals that already signed in on the way here — the // signup-first joiner, and the creator following ?src=home out of their own // Eleven page — because in both the home-tab session is minutes old and can // complete the save silently. Everyone else keeps the ordinary path, where // Eleven asks at the end of the conversation. // // Never in the native embed (the apps own their sign-in), and never without a // home server to save into (self-hosters). export function autoEnrolOnClaim({ signedUp, fromHome, homeOrigin, embed }) { if (embed) return false; if (!(typeof homeOrigin === "string" && homeOrigin.startsWith("http"))) return false; return !!(signedUp || fromHome); } // 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= 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= (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); }