web: one sign-in per space — the from-home claim enrols itself (#1472)
Creating a space from your Eleven page asked you to sign in again at the end of the setup conversation, minutes after signing in at home — and until you answered, home's saved entry held only the address, never this space's key. finish() already settles this for the signup-first joiner: enrol at claim time through the still-live home session, no ceremony, no tap. The from-home creator is the same situation. autoEnrolOnClaim (pure, tested) now names both. Claude-Session: https://claude.ai/code/session_01GUA1EJFYwgr2nZNVBRFf8k
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+55
−7
changelog/2026-08-29-one-signin-per-space.md+4 −0test/prejoin.test.mjs+13 −0web/static/join.js+22 −7web/static/prejoin.js+16 −0
diff --git a/changelog/2026-08-29-one-signin-per-space.md b/changelog/2026-08-29-one-signin-per-space.md| new file mode 100644 |
| index 0000000..f9d019f |
| --- /dev/null |
| +++ b/changelog/2026-08-29-one-signin-per-space.md |
| @@ -0,0 +1,4 @@ |
| +🤖 Signing in now happens once when you get a new space, not again at the end. |
| +If you came from your Eleven page, your new space saves itself to your account |
| +the moment it's yours — so Eleven's setup chat just confirms your key is safe |
| +instead of asking you to sign in all over again. |
diff --git a/test/prejoin.test.mjs b/test/prejoin.test.mjs| index 4cd3989..e0d374d 100644 |
| --- a/test/prejoin.test.mjs |
| +++ b/test/prejoin.test.mjs |
| @@ -3,6 +3,7 @@ import test from "node:test"; |
| import assert from "node:assert/strict"; |
| import { |
| PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL, |
| + autoEnrolOnClaim, |
| } from "../web/static/prejoin.js"; |
| const HOME = "https://home.example.com"; |
| @@ -33,6 +34,18 @@ test("offerPrejoin: a setup link from a signed-in home page never leads with sig |
| assert.equal(offerPrejoin({ ...base, kind: "setup", fromHome: false }), true); |
| }); |
| +// One sign-in per space: an arrival that already signed in on the way here |
| +// enrols itself at claim time, so nothing asks again later. |
| +test("autoEnrolOnClaim: the two already-signed-in arrivals, and nobody else", () => { |
| + const at = { homeOrigin: HOME, embed: false }; |
| + assert.equal(autoEnrolOnClaim({ ...at, signedUp: true }), true); |
| + assert.equal(autoEnrolOnClaim({ ...at, fromHome: true }), true); |
| + assert.equal(autoEnrolOnClaim({ ...at }), false, "a cold joiner still meets the ask"); |
| + assert.equal(autoEnrolOnClaim({ ...at, fromHome: true, embed: true }), false); |
| + assert.equal(autoEnrolOnClaim({ signedUp: true, fromHome: true, homeOrigin: "", embed: false }), false); |
| + assert.equal(autoEnrolOnClaim({ signedUp: true, homeOrigin: "__HOME_ORIGIN__", embed: false }), false); |
| +}); |
| + |
| test("round trip: stash then read back on a genuine return", () => { |
| const now = 1_000_000; |
| const stored = prejoinStash({ frag: "e1.SECRET~Ann~bkey", nonce: "n0nce", now }); |
diff --git a/web/static/join.js b/web/static/join.js| index af479e9..7e43d08 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -16,7 +16,7 @@ import { |
| } from "/static/crypto.js"; |
| import { parseInviteFragment } from "/static/invite.js"; |
| import { appSchemeFrom } from "/static/ui.js"; |
| -import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL } from "/static/prejoin.js"; |
| +import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL, autoEnrolOnClaim } from "/static/prejoin.js"; |
| import { INVITER_HINT_KEY, inviterHintStash } from "/static/invitehint.js"; |
| import { launchEnrol } from "/static/backup.js"; |
| @@ -547,12 +547,27 @@ async function finish(id, name, pub, priv, wrappedSelf = "") { |
| } catch {} |
| return; |
| } |
| - // Signed up before joining: save this brand-new session to the account NOW, |
| - // via the /enroll round trip. The home-tab session from the sign-in moments |
| - // ago is still live, so enrolment completes hands-free (payload.auto) and |
| - // lands back at "/" with the standard nonce marker — the joiner arrives in |
| - // the chat already backed up, and Eleven never has to ask. |
| - if (prejoin.signedUp && (window.__homeOrigin || "").startsWith("http")) { |
| + // Already signed in on the way here: save this brand-new session to the |
| + // account NOW, via the /enroll round trip. Two arrivals qualify — the |
| + // signup-first joiner (prejoin), and someone who created this space from |
| + // their Eleven page (?src=home). Either way the home-tab session from |
| + // minutes ago is still live, so enrolment completes hands-free |
| + // (payload.auto) and lands back at "/" with the standard nonce marker. |
| + // |
| + // Doing it HERE is what makes signing in a once-per-space act. The |
| + // from-home creator used to be asked again at the end of the setup |
| + // conversation (keys-home) — and, until it was answered, home's list held |
| + // only the address-only stub the create saved, with none of this space's |
| + // key. One automatic enrolment at claim time settles both: the key is in |
| + // the account before Eleven says hello, so the conversation's key step |
| + // sees bridge.enrolled and simply confirms it. |
| + // |
| + // If the session has gone stale, /enroll's own card stands (enroll.js |
| + // never runs a ceremony unprompted) — one honest ask, in one place. |
| + if (autoEnrolOnClaim({ |
| + signedUp: prejoin.signedUp, fromHome, |
| + homeOrigin: window.__homeOrigin || "", embed: !!window.__lchatEmbed, |
| + })) { |
| return launchEnrol({ token: claim.token, name, userPub: pub, userPriv: priv }, { auto: true }); |
| } |
| await plugIn(); |
diff --git a/web/static/prejoin.js b/web/static/prejoin.js| index 2b4b547..54f2e93 100644 |
| --- a/web/static/prejoin.js |
| +++ b/web/static/prejoin.js |
| @@ -28,6 +28,22 @@ export function offerPrejoin({ kind, homeOrigin, embed, native, signedUp, fromHo |
| 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. |