| 1 | // Pure decision helpers for the signup-first join page (docs/superpowers/specs/ |
| 2 | // 2026-07-31-signup-first-join-design.md): which links lead with the sign-in |
| 3 | // card, and how the round trip to the home server is stamped so a forged or |
| 4 | // stale ?su= marker never reads as "signed up". No DOM, no storage — join.js |
| 5 | // owns those — so this is Node-testable (test/prejoin.test.mjs). |
| 6 | |
| 7 | export const PREJOIN_STASH_KEY = "lchat_prejoin"; |
| 8 | |
| 9 | // A pre-join round trip should be minutes; an hour-old stash is an abandoned |
| 10 | // one — reading it as a fresh sign-up would put a stale "saved" promise on the |
| 11 | // welcome card. |
| 12 | const MAX_AGE_MS = 60 * 60 * 1000; |
| 13 | |
| 14 | // offerPrejoin: the lead card shows only to a FRESH person (join/setup kinds — |
| 15 | // device links and adopts already have an identity), only where a home server |
| 16 | // exists to sign into, and never in the native embed/wrapper (the apps own |
| 17 | // their sign-in). signedUp means the round trip already happened. |
| 18 | export function offerPrejoin({ kind, homeOrigin, embed, native, signedUp }) { |
| 19 | if (embed || native || signedUp) return false; |
| 20 | if (kind !== "join" && kind !== "setup") return false; |
| 21 | return typeof homeOrigin === "string" && homeOrigin.startsWith("http"); |
| 22 | } |
| 23 | |
| 24 | // prejoinStash packages what must survive the navigation away: the invite |
| 25 | // fragment (the secret NEVER travels to the home origin — it waits here, on |
| 26 | // the instance origin) and the nonce the return marker must echo. |
| 27 | export function prejoinStash({ frag, nonce, now }) { |
| 28 | return JSON.stringify({ v: 1, frag: String(frag || ""), nonce: String(nonce || ""), at: now }); |
| 29 | } |
| 30 | |
| 31 | // readPrejoinReturn: ?su=<nonce> marks a return from the home sign-in. It |
| 32 | // counts only when the nonce matches a fresh stash — anyone can type ?su=1, |
| 33 | // but only this browser holds the stash minted before leaving. |
| 34 | export function readPrejoinReturn({ search, storedJSON, now }) { |
| 35 | const none = { signedUp: false, frag: "" }; |
| 36 | const su = new URLSearchParams(search || "").get("su") || ""; |
| 37 | if (!su) return none; |
| 38 | let stash = null; |
| 39 | try { stash = JSON.parse(storedJSON || "null"); } catch { return none; } |
| 40 | if (!stash || typeof stash.nonce !== "string" || stash.nonce !== su) return none; |
| 41 | if (typeof stash.at !== "number" || now < stash.at || now - stash.at > MAX_AGE_MS) return none; |
| 42 | return { signedUp: true, frag: String(stash.frag || "") }; |
| 43 | } |
| 44 | |
| 45 | // homeSigninURL builds the door: the home page reads #joinret=<url> (a |
| 46 | // fragment, so the return URL never appears in home's server logs) and sends |
| 47 | // the person back to it once signed in. Only the nonce rides the return URL. |
| 48 | export function homeSigninURL(homeOrigin, origin, pathname, nonce) { |
| 49 | const ret = origin + pathname + "?su=" + encodeURIComponent(nonce); |
| 50 | return homeOrigin.replace(/\/$/, "") + "/#joinret=" + encodeURIComponent(ret); |
| 51 | } |
| 52 | |