// 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= 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); }