| 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 | // |
| 19 | // fromHome (the setup link's ?src=home) is the other established identity: |
| 20 | // this person just created the space FROM their signed-in Eleven page, which |
| 21 | // already saved it to their account — asking them to sign in again, one tap |
| 22 | // after signing in, is the flow contradicting itself. They meet the sign-in |
| 23 | // once more at the end, where it belongs: the setup conversation's keys-home |
| 24 | // step, adding this space's key to the account they already have. |
| 25 | export function offerPrejoin({ kind, homeOrigin, embed, native, signedUp, fromHome }) { |
| 26 | if (embed || native || signedUp || fromHome) return false; |
| 27 | if (kind !== "join" && kind !== "setup") return false; |
| 28 | return typeof homeOrigin === "string" && homeOrigin.startsWith("http"); |
| 29 | } |
| 30 | |
| 31 | // autoEnrolOnClaim: whether a just-claimed session should be pushed into the |
| 32 | // person's Eleven account immediately, with no tap (launchEnrol's `auto`). |
| 33 | // True for the two arrivals that already signed in on the way here — the |
| 34 | // signup-first joiner, and the creator following ?src=home out of their own |
| 35 | // Eleven page — because in both the home-tab session is minutes old and can |
| 36 | // complete the save silently. Everyone else keeps the ordinary path, where |
| 37 | // Eleven asks at the end of the conversation. |
| 38 | // |
| 39 | // Never in the native embed (the apps own their sign-in), and never without a |
| 40 | // home server to save into (self-hosters). |
| 41 | export function autoEnrolOnClaim({ signedUp, fromHome, homeOrigin, embed }) { |
| 42 | if (embed) return false; |
| 43 | if (!(typeof homeOrigin === "string" && homeOrigin.startsWith("http"))) return false; |
| 44 | return !!(signedUp || fromHome); |
| 45 | } |
| 46 | |
| 47 | // prejoinStash packages what must survive the navigation away: the invite |
| 48 | // fragment (the secret NEVER travels to the home origin — it waits here, on |
| 49 | // the instance origin) and the nonce the return marker must echo. |
| 50 | export function prejoinStash({ frag, nonce, now }) { |
| 51 | return JSON.stringify({ v: 1, frag: String(frag || ""), nonce: String(nonce || ""), at: now }); |
| 52 | } |
| 53 | |
| 54 | // readPrejoinReturn: ?su=<nonce> marks a return from the home sign-in. It |
| 55 | // counts only when the nonce matches a fresh stash — anyone can type ?su=1, |
| 56 | // but only this browser holds the stash minted before leaving. |
| 57 | export function readPrejoinReturn({ search, storedJSON, now }) { |
| 58 | const none = { signedUp: false, frag: "" }; |
| 59 | const su = new URLSearchParams(search || "").get("su") || ""; |
| 60 | if (!su) return none; |
| 61 | let stash = null; |
| 62 | try { stash = JSON.parse(storedJSON || "null"); } catch { return none; } |
| 63 | if (!stash || typeof stash.nonce !== "string" || stash.nonce !== su) return none; |
| 64 | if (typeof stash.at !== "number" || now < stash.at || now - stash.at > MAX_AGE_MS) return none; |
| 65 | return { signedUp: true, frag: String(stash.frag || "") }; |
| 66 | } |
| 67 | |
| 68 | // homeSigninURL builds the door: the home page reads #joinret=<url> (a |
| 69 | // fragment, so the return URL never appears in home's server logs) and sends |
| 70 | // the person back to it once signed in. Only the nonce rides the return URL. |
| 71 | export function homeSigninURL(homeOrigin, origin, pathname, nonce) { |
| 72 | const ret = origin + pathname + "?su=" + encodeURIComponent(nonce); |
| 73 | return homeOrigin.replace(/\/$/, "") + "/#joinret=" + encodeURIComponent(ret); |
| 74 | } |
| 75 | |