eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev c040f49c1bce1bdbf31def7cdaf0e65c83ef1d1a parent 94dd89d
4 files changed, +55 −7
  • changelog/2026-08-29-one-signin-per-space.md +4 −0
  • test/prejoin.test.mjs +13 −0
  • web/static/join.js +22 −7
  • web/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.