eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

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
7export 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.
12const 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.
25export 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).
41export 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.
50export 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.
57export 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.
71export function homeSigninURL(homeOrigin, origin, pathname, nonce) {
72 const ret = origin + pathname + "?su=" + encodeURIComponent(nonce);
73 return homeOrigin.replace(/\/$/, "") + "/#joinret=" + encodeURIComponent(ret);
74}
75