eleven / ocho Public

ios: don't tell a signed-out device its key is in an Eleven account (#1476)

The native entries in both conversations treated "running in the app" as
proof of an Eleven account — but what saves the key is HomeSync.pushSoon,
which returns silently with no session. A signed-out device was told its
key was safe in an account that had never seen it.

Gate both on bridge.homeSignedIn (live), with an honest second entry for
the signed-out half ("This device holds this space's key"), and state
bridge.canEnrol as false natively so the finish nag and its chip stay off
by decision rather than by an undefined lookup.


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 e4662351b1af9157bbd7aa02bf229e8969044a64 parent 38a736c
7 files changed, +150 −13
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +13 −1
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +14 −1
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +14 −1
  • ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift +39 −2
  • test/flow.test.mjs +43 −1
  • web/static/setup.flow.js +14 −4
  • web/static/welcome.flow.js +13 −3
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
index ee0e381..0a8596d 100644
--- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift
@@ -144,7 +144,19 @@ public final class ElevenConversationModel: ObservableObject {
env.live["bridge.myName"] = { [instance] in .string(instance.screenName) }
env.live["bridge.enrolled"] = { .bool(false) }
env.live["bridge.fromHome"] = { .bool(false) }
- env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in
+ // Is this device actually signed in to Eleven? Read LIVE, per lookup:
+ // claiming a space pushes it (key and all) into the account's vault
+ // via HomeSync.pushSoon, which returns SILENTLY when there's no
+ // session — so a signed-out device used to be told its key was "saved
+ // with your Eleven account" when nothing had been pushed anywhere.
+ // The flows branch on this now; both halves are honest.
+ env.live["bridge.homeSignedIn"] = { .bool(HomeSessionStore.load() != nil) }
+ // Nothing in a native conversation can run a sign-in: Eleven sign-in
+ // lives in the app's own surfaces, so a "Sign in" chip here would be
+ // dead. Stated rather than left undefined — the flows' finish nag and
+ // its chip both gate on it, and an accident is not a decision.
+ env.live["bridge.canEnrol"] = { .bool(false) }
+ env.live["accessBackedUp"] = { .bool(HomeSessionStore.load() != nil) }
let hop = pendingThreadId
pendingHop = hop
self.env = env
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
index 08b8710..bc75ccc 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
@@ -687,7 +687,12 @@
"id": "keys",
"entries": [
{
- "if": "native",
+ "if": {
+ "all": [
+ "native",
+ "bridge.homeSignedIn"
+ ]
+ },
"set": {
"vars.backedUp": true
},
@@ -697,6 +702,14 @@
},
"goto": "finish"
},
+ {
+ "if": "native",
+ "recordIfNew": {
+ "label": "This device holds this space's key",
+ "value": "device"
+ },
+ "goto": "finish"
+ },
{
"if": "bridge.enrolled",
"set": {
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
index 32217d7..2d312ee 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
@@ -54,13 +54,26 @@
"id": "access",
"entries": [
{
- "if": "native",
+ "if": {
+ "all": [
+ "native",
+ "bridge.homeSignedIn"
+ ]
+ },
"recordIfNew": {
"label": "Your access is already secured by your Eleven sign-in",
"value": "native"
},
"goto": "name"
},
+ {
+ "if": "native",
+ "recordIfNew": {
+ "label": "Your access lives on this device",
+ "value": "device"
+ },
+ "goto": "name"
+ },
{
"if": {
"any": [
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
index 4c51cc7..8dc23de 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/FlowEngineTests.swift
@@ -74,7 +74,8 @@ final class FlowEngineTests: XCTestCase {
try XCTUnwrap(AppFlows.flow(name), "bundled flow \(name).json")
}
- func nativeEnv(name: String = "", space: String = "Test Space") -> FlowEnv {
+ func nativeEnv(name: String = "", space: String = "Test Space",
+ homeSignedIn: Bool = true) -> FlowEnv {
let env = FlowEnv()
env.flags["native"] = .bool(true)
env.flags["suggested"] = .string("")
@@ -91,7 +92,9 @@ final class FlowEngineTests: XCTestCase {
// this browser" and skipped past its chips, quietly rotting
// testQuickFormTrailingChip (red on main since the #1377 gate landed).
env.live["bridge.canEnrol"] = { .bool(true) }
- env.live["accessBackedUp"] = { .bool(true) } // native: secured by the sign-in
+ // Mirrors ElevenConversationModel: both read the live home session.
+ env.live["bridge.homeSignedIn"] = { .bool(homeSignedIn) }
+ env.live["accessBackedUp"] = { .bool(homeSignedIn) }
return env
}
@@ -164,6 +167,40 @@ final class FlowEngineTests: XCTestCase {
XCTAssertTrue(allChips.contains("Open admin"))
}
+ // MARK: native without an Eleven session
+
+ // Twin of "setup: native without an Eleven session says where the key
+ // really is". Claiming a space pushes it into the account's vault through
+ // HomeSync, which no-ops silently when signed out — so the conversation
+ // must say where the key actually is instead of naming an account.
+ func testNativeWithoutHomeSessionIsHonest() async throws {
+ let flow = try moduleFlow("setup")
+ let io = Harness()
+ io.verbResults["setGroupName"] = .string("ok")
+ let env = nativeEnv(name: "Paul", homeSignedIn: false)
+ // Mirrors ElevenConversationModel: a sign-in can't be run from inside
+ // a native conversation, so the flows must not offer one.
+ env.live["bridge.canEnrol"] = { .bool(false) }
+ let engine = FlowEngine(flow: flow, env: env, io: io)
+ engine.start()
+ await io.settle()
+ defer { withExtendedLifetime(engine) {} }
+
+ await io.answer("Team Awesome")
+ env.space["name"] = .string("Team Awesome")
+ await io.tap("Skip for now")
+ await io.tap("Members can invite people")
+ await io.tap("Keep both on")
+ await io.tap("Not now")
+
+ let keys = io.records.first { $0.step == "keys" }
+ XCTAssertEqual(keys?.label, "This device holds this space's key")
+ XCTAssertEqual(keys?.value, "device")
+ XCTAssertFalse(io.verbLog.contains { $0.0 == "savePasskey" }, "no dead sign-in chip")
+ XCTAssertFalse(io.transcript.flatMap { $0.chips ?? [] }.map(\.label).contains("Sign in"))
+ XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }, "still reaches finish")
+ }
+
// MARK: input.confirm — an email answered as a name
// Twin of "setup: an email answered as a name is confirmed, not swallowed"
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index 7ec25af..f0feff5 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -13,7 +13,7 @@ const tick = () => new Promise((r) => setImmediate(r));
// A fake io that mirrors the real engine surface: say dedupes by key||text
// (like eleven.js) but always re-arms chips; verbs return scripted results.
-function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, canEnrol = true, myName = "", planBook = null, envExtra = {} } = {}) {
+function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, canEnrol = true, myName = "", homeSignedIn = true, planBook = null, envExtra = {} } = {}) {
const h = {
transcript: [], // { text, chips: [labels], extra }
verbLog: [],
@@ -27,6 +27,7 @@ function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, ca
fromHome,
canEnrol,
myName,
+ homeSignedIn,
};
h.env = {
threadId: "T",
@@ -37,6 +38,9 @@ function harness({ verbs = {}, done = {}, enrolled = false, fromHome = false, ca
myName: () => h.myName,
enrolled: () => h.enrolled,
fromHome: () => h.fromHome,
+ // Native only: whether this device holds a live Eleven session, which
+ // decides whether claiming a space really did push its key anywhere.
+ homeSignedIn: () => h.homeSignedIn,
// Whether signing in is possible at all (#1377) — false on self-hosted
// spaces with no home server; the flows skip the un-actionable pitch.
canEnrol: () => h.canEnrol,
@@ -684,6 +688,44 @@ await test("welcome: the joiner's name step confirms an email the same way", asy
assert.equal(h.records.filter((r) => r.step === "name").length, 1);
});
+// Native, signed OUT of Eleven: claiming a space pushes nothing (HomeSync
+// no-ops without a session), so the conversation must not claim an account
+// that isn't there (Paul, 2026-08-29).
+await test("setup: native without an Eleven session says where the key really is", async () => {
+ // canEnrol false mirrors the native bridge: a sign-in can't be run from
+ // inside the conversation, so the flows must not offer one.
+ const h = harness({ myName: "Paul", homeSignedIn: false, canEnrol: false,
+ verbs: { setGroupName: () => "ok", setContactEmail: () => "ok" } });
+ h.env.native = true;
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+ await h.answer("Team Awesome");
+ await h.tap("Skip for now");
+ await h.tap("Members can invite people");
+ await h.tap("Keep both on");
+ await h.tap("Not now");
+
+ assert.deepEqual(h.records.find((r) => r.step === "keys"),
+ { step: "keys", label: "This device holds this space's key", value: "device" });
+ // Still no pitch and no dead chip — signing in lives in the app's own
+ // surfaces, not in this conversation.
+ assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false);
+ assert.equal(h.verbLog.some((v) => v[0] === "savePasskey"), false);
+ assert.equal(h.transcript.flatMap((t) => t.chips).includes("Sign in"), false);
+ assert.ok(h.verbLog.some((v) => v[0] === "markSetupComplete"), "still reaches finish");
+});
+
+await test("welcome: native without an Eleven session records the device, not an account", async () => {
+ const h = harness({ homeSignedIn: false, canEnrol: false, envExtra: welcomeEnv() });
+ h.env.native = true;
+ h.env.accessBackedUp = () => false;
+ runFlow(WELCOME_FLOW, h.env, h.io);
+ await tick();
+ assert.deepEqual(h.records.find((r) => r.step === "access"),
+ { step: "access", label: "Your access lives on this device", value: "device" });
+ assert.equal(h.transcript.some((t) => /Before anything else/.test(t.text)), false, "no pitch");
+});
+
// The quick-form fast path: both conversations' FIRST interactive step offers
// a right-aligned "Fill in a form instead" chip whose tap opens the host form
// (the openQuickForm verb) WITHOUT consuming the step — the conversation stays
diff --git a/web/static/setup.flow.js b/web/static/setup.flow.js
index a06c4b4..063b734 100644
--- a/web/static/setup.flow.js
+++ b/web/static/setup.flow.js
@@ -471,14 +471,24 @@ export default {
id: "keys",
entries: [
{
- // Native shells (iOS/Mac): the space was created from a signed-in
- // Eleven account — its key is already saved to the creator's vault,
- // so the whole passkey pitch is moot.
- if: "native",
+ // Native shells (iOS/Mac) WITH a live Eleven session: claiming a
+ // space pushes it — key included — straight into that account's
+ // vault (addInstance → HomeSync.pushSoon), so the pitch is moot.
+ if: { all: ["native", "bridge.homeSignedIn"] },
set: { "vars.backedUp": true },
recordIfNew: { label: "Your space's key is saved with your Eleven account", value: "native" },
goto: "finish",
},
+ {
+ // Native, signed OUT: the push above no-ops silently, so claiming
+ // "saved with your Eleven account" would be a lie about the one
+ // thing this step exists to be honest about. Say where the key
+ // actually is. No pitch: signing in to Eleven lives in the app's
+ // own surfaces, not in this conversation.
+ if: "native",
+ recordIfNew: { label: "This device holds this space's key", value: "device" },
+ goto: "finish",
+ },
{
if: "bridge.enrolled",
set: { "vars.backedUp": true },
diff --git a/web/static/welcome.flow.js b/web/static/welcome.flow.js
index 7037c17..6598a93 100644
--- a/web/static/welcome.flow.js
+++ b/web/static/welcome.flow.js
@@ -39,12 +39,22 @@ export default {
id: "access",
entries: [
{
- // Native shells (iOS/Mac): access is already secured by Sign in
- // with Eleven — no browser key to lose, so the passkey ask is moot.
- if: "native",
+ // Native shells (iOS/Mac) WITH a live Eleven session: access is
+ // already secured by that sign-in — the app pushed this space into
+ // its vault as it joined — so the ask is moot.
+ if: { all: ["native", "bridge.homeSignedIn"] },
recordIfNew: { label: "Your access is already secured by your Eleven sign-in", value: "native" },
goto: "name",
},
+ {
+ // Native, signed OUT: nothing was pushed anywhere. Record where the
+ // access actually lives (accessBackedUp stays false, so the finish
+ // line keeps its honest reminder) rather than claiming an account
+ // that isn't there.
+ if: "native",
+ recordIfNew: { label: "Your access lives on this device", value: "device" },
+ goto: "name",
+ },
{
if: { any: ["bridge.enrolled", "done"] },
recordIfNew: { label: "Signed in — your access is safe", value: "passkey" },